Reference
API reference
The programmatic surface for a Descant tenant. This document is the CONTRACTED subset, not the whole product: the dashboard can do things no operation here covers yet, and the set grows as operations are specified. An absent capability means it is not contracted, never that it is forbidden.
Contract version 1.0.0. Every request goes to https://app.descant.run. The machine-readable document this page is generated from is openapi.json (OpenAPI 3.1). A shell client for the same operations is on the CLI page.
Authentication
Send the credential as a bearer token: Authorization: Bearer …. The schemes this API accepts:
apiKey— A tenant API key, created in the dashboard and shown once. The tenant is taken from the key, never from the request. Each operation's `security` entry names the scope (`<family>:<read|write>`) the key must carry; a key without it is refused 403 before the operation runs.
GET /api/v1/runs
Operation customer.runs.list. List this key's tenant's runs, newest first.
Read-only. The key's tenant is taken from the matched key row and never from the request, so a key cannot read another tenant's runs. Rate limited per source before authentication, so an unauthenticated caller cannot probe cheaply. Ownership-scoped (#3307): runs of a repository the tenant has since removed are still returned, because `customer.repos.list` still shows that repository and eliding its runs would lose audit history. Paged by an opaque page token — a run history is unbounded, and this list ALREADY stopped at fifty rows, silently, with no way to ask for the fifty-first; follow `nextPageToken` until it is `null`. The rows arrive as `items`, not `runs`: the array was never going to stay complete, and a renamed field is a break a caller can see rather than a truncation it cannot. `billing` is a tenant fact read once per request and rides every page unchanged. `repositoryId`, `state` and the `createdAfter`/`createdBefore` window each narrow the page and are each optional; absent means unfiltered. `invalid_query` covers a malformed page token — one this server did not mint, or one minted by another paged read — which is refused rather than read as `start over`.
Security: apiKey
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
limit | query | integer | no | Page size. Defaults to 50, clamped to 200; the applied value is echoed. |
pageToken | query | string | no | Opaque, server-minted. Pass the previous response's `nextPageToken` verbatim; never construct one. |
repositoryId | query | string | no | Only this repository's runs. The id `customer.repos.list` returns, which is the only programmatic source of one. A repository belonging to another tenant matches nothing rather than erroring — the ownership join answers before the filter does. |
state | query | array of "idle" | "picking" | "planning" | "implementing" | "opening_pr" | "under_review" | "resolving" | "merging" | "capturing_learnings" | "reviewing_learnings" | "resolving_learnings" | "merging_learnings" | "filing_residuals" | "grooming_issues" | "refreshing_wiki" | "verifying" | "positioning_pr" | "positioning_resolve" | "self_reviewing" | "fixing_ci" | "decomposing" | "done" | "failed" | "cancelled" | no | Repeatable. The run's CURRENT state, not its outcome — `failed` and `cancelled` are terminal, `under_review` is not. Absent or empty means every state. |
createdAfter | query | string | no | Runs created at or after this instant, INCLUSIVE. Filters the column the list orders by. |
createdBefore | query | string | no | Runs created at or before this instant, INCLUSIVE. Filters the column the list orders by. |
Response
| field | type | required | description |
|---|---|---|---|
items | array of object | yes | |
items[].id | string | yes | |
items[].issue | string | yes | The issue's external identifier, as the provider names it. |
items[].issueTitle | string | null | yes | Snapshotted on the run (#3262). `null` on legacy runs and on seed paths that could not fetch it. |
items[].state | string | yes | The pipeline state. Open by design: states are added as the pipeline grows. |
items[].priority | string | null | yes | |
items[].prNumber | integer | null | yes | |
items[].provider | string | yes | |
items[].repoOwner | string | yes | |
items[].repoName | string | yes | |
items[].startedAt | string | yes | |
items[].finishedAt | string | null | yes | |
items[].dispatchBlockedReason | "slot" | "vendor_budget" | null | yes | `slot`: the run's account is at its concurrency cap (#1995). `vendor_budget`: the fleet vendor budget, or this tenant's fair share of it, is exhausted (#7553), and only ever present when the operator has armed that budget. `null` on a run that is not parked. |
items[].dispatchWaiting | "paused-no-credits" | "paused-cap" | "waiting-for-slot" | "waiting-for-vendor-budget" | null | yes | The dashboard's own reading of why this run rests. `null` when it is not waiting on any of these. |
nextPageToken | string | null | yes | `null` when this is the last page. |
limit | integer | yes | The page size actually applied, after clamping. |
billing | object | null | yes | |
billing.pauseReason | "balance" | "cap" | "operator" | null | yes | The persisted pause reason, narrowed to the known set — an unrecognised future value reads as `null` rather than leaking. |
billing.outOfCredits | boolean | yes | Derived: the pause is a balance pause rather than an operator or cap pause. `false` with no billing signal. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/json | invalid_query — A query parameter failed validation. (legacy `{ error }` body) |
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but its scopes do not include the one this operation requires (the `security` entry names it). Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 429 | application/json | rate_limited — Too many requests for this credential or source. (legacy `{ error }` body) |
| 500 | application/json | internal_error — The request failed for a reason the caller cannot fix. (legacy `{ error }` body) |
GET /api/v1/runs/{runId}
Operation customer.runs.get. Read one run's timeline and findings, exactly as its own page shows them.
The run page's live snapshot under the key's own tenant: the five canonical phases as `deriveTimeline` derives them, their statuses and mapped copy, the server clocks a stall is measured against, the review findings and the follow-up counts. IT PUBLISHES WHAT THE PAGE RENDERS, through the same projection — so the two cannot tell a customer different stories about one run, and a test pins that they call the same function. The MAPPED failure copy crosses and the raw failure code does not; the derived findings view crosses and the raw review checkpoint does not. Measure every age against `serverNowMs` rather than the caller's clock. A missing run and another tenant's run are one indistinguishable `not_found`, so this is not an oracle for what somebody else owns.
Security: apiKey
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
runId | path | string | yes |
Response
| field | type | required | description |
|---|---|---|---|
state | string | yes | The pipeline state. Open by design, as the list read's is. |
steps | array of object | yes | The five canonical phases as `deriveTimeline` derives them — the SAME call the page makes. |
steps[].key | string | yes | |
steps[].label | string | yes | |
steps[].status | "completed" | "current" | "failed" | "held" | "pending" | "skipped" | "stalled" | "warning" | yes | |
steps[].errorMessage | string | null | yes | The MAPPED customer-facing copy, never the raw failure code — the projection strips that before it reaches any surface. |
steps[].reviewIterations | integer | no | How many review passes actually RAN. Present on the review step whenever that count is at least one, so `1` is an ordinary reviewed run rather than an anomaly; absent when no pass ran, and absent on every other step. The count is not a raw tally of review states: the pre-review positioning step maps to the same phase and is not a pass, and a drift-reposition abort re-enters the review state without running the reviewer, so it is subtracted rather than counted. |
stepDetails | object | yes | Sub-steps keyed by step `key`, for the steps that have them. |
findings | object | yes | |
findings.kind | "list" | yes | |
findings.items | array of object | yes | |
findings.items[].id | string | yes | The stable finding id (`review-comment:<id>`). |
findings.items[].threadUrl | string | null | yes | Deep link to the PR review thread, or `null` where the coordinates make one underivable. |
findings.items[].resolution | "fixed" | "rebutted" | "outOfScope" | "resolving" | "pending" | yes | |
findings.items[].rationale | string | null | yes | The classifier's recorded prose about this tenant's own PR. `null` on a not-yet-classified item. FREE TEXT — see this family's note on `findings`. |
findings.kind | "clean" | yes | |
findings.kind | "resolvedSummary" | yes | |
findings.rounds | integer | yes | |
findings.kind | "outOfDiff" | yes | |
findings.findingsCount | integer | null | yes | |
findings.kind | "reviewUnresolvable" | yes | |
findings.findingsCount | integer | null | yes | |
findings.kind | "unrecorded" | yes | |
findings.findingsCount | integer | null | yes | |
serverNowMs | integer | yes | The server clock this response was built at. Anchor every age against THIS, not the caller's clock. |
stallAnchorMs | integer | yes | When the current customer-VISIBLE phase was entered — what the stall clock measures from. A multi-state visible phase deliberately does not reset it. |
stateEnteredAtMs | integer | yes | The INTERNAL state's entry, verbatim — not bumped on a resolve self-loop. |
resolveProgressAtMs | integer | null | yes | |
lastHeartbeatAtMs | integer | null | yes | |
finishedAtMs | integer | null | yes | |
currentStepNotStarted | boolean | yes | The run is at a phase that has produced no heartbeat, so it renders `stalled` rather than `current`. Derived ONCE server-side so every pane reads the same answer. |
isTerminal | boolean | yes | |
awaitingManualMerge | boolean | yes | A done run whose deliberately-unmerged PR still awaits a human. |
skippedStepNote | string | yes | The note a `skipped` Merge step renders. |
mergeStepNote | string | null | yes | Non-null only where the merge took the host-backstop path because the Checks permission was never granted. |
heldStepNote | string | null | yes | Non-null only where the timeline actually carries a `held` Merge step. |
baseBranch | string | null | yes | |
runKind | string | yes | |
seededFromRunId | string | null | yes | The run this one was seeded from, where it was. |
deferredFollowUpCount | integer | yes | |
discretionFollowUpCount | integer | yes | |
secondOrderFollowUpCount | integer | yes | |
diminishingReturnsFollowUpCount | integer | yes | |
unresolvedFollowUpCount | integer | yes | |
adjudicatedSetAsideCount | integer | yes | |
deferredOutOfDiffCount | integer | yes | |
unanchoredCount | integer | yes | |
toolingProposalCount | integer | yes | |
selfReviewExplainedCount | integer | yes | |
selfReviewResidualCount | integer | yes | |
selfReviewResidualsWithAttempts | integer | yes |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_path — A path parameter failed validation. |
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but its scopes do not include the one this operation requires (the `security` entry names it). Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/problem+json | not_found — No such resource for this caller. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
POST /api/v1/runs/{runId}/cancel
Operation customer.runs.cancel. Stop a run that should not be running.
The write that stops spend. Answers WHICH ending it reached rather than a boolean: `cancel_requested` is recorded-but-not-yet-stopped and calls for a re-read, while `already_finished` is final and calling again will never change it. Refusals keep their own discriminant, and an `outcome_unknown` answer means the cancel MAY have landed: verify the run before calling again. A deployment that is not wired to the runner answers `internal_error`, which is an operator problem and not something a caller can fix by retrying.
Security: apiKey
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
runId | path | string | yes |
Response
| field | type | required | description |
|---|---|---|---|
outcome | "cancelled" | "cancel_requested" | "already_finished" | "privately_dispatched" | yes | How the cancel ended. `cancelled` is stopped. `cancel_requested` is RECORDED BUT NOT YET STOPPED — re-read the run rather than calling again. `already_finished` means there was nothing to stop and never will be. `privately_dispatched` means the work left this estate and cannot be recalled from here. |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but its scopes do not include the one this operation requires (the `security` entry names it). Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/json | not_found — No such resource for this caller. (legacy `{ error }` body) |
| 429 | application/json | rate_limited — Too many requests for this credential or source. (legacy `{ error }` body) |
| 500 | application/json | internal_error — The request failed for a reason the caller cannot fix. (legacy `{ error }` body) |
| 502 | application/json | upstream_auth — An upstream service rejected this deployment's credential. (legacy `{ error }` body) |
| 503 | application/json | upstream_timeout — An upstream service did not answer in time. / upstream_unavailable — A service this operation depends on did not answer. (legacy `{ error }` body) |
| 504 | application/json | outcome_unknown — The action may have taken effect; verify before retrying. (legacy `{ error }` body) |
GET /api/v1/repos
Operation customer.repos.list. List this key's tenant's repositories.
Read-only, and the entry point for every repo-scoped operation: the `id` each of those takes is obtainable nowhere else on the programmatic surface. The key's tenant is taken from the matched key row and never from the request, so a key cannot list another tenant's repositories. Rate limited per source BEFORE authentication, so an unauthenticated caller cannot probe cheaply. Unpaginated — the whole list comes back.
Security: apiKey
Response
| field | type | required | description |
|---|---|---|---|
repos | array of object | yes | |
repos[].id | string | yes | The tenant-repo id. What every repo-scoped operation takes. |
repos[].owner | string | yes | The account or organisation that owns the repository on its provider. |
repos[].repo | string | yes | The repository name, without the owner. |
repos[].onboardedStatus | string | yes | Where this repository is in onboarding, as the column stores it. `pending` until both installation ids are populated and the validation smoke test succeeds, then `active`; `disconnected` is the terminal state after self-serve offboarding. NOT a closed set — the column carries no constraint, so treat an unrecognised value as unknown rather than as an error. |
repos[].paused | boolean | yes | Whether the customer has paused this repository. A paused repository is not dispatched. |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but its scopes do not include the one this operation requires (the `security` entry names it). Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 429 | application/json | rate_limited — Too many requests for this credential or source. (legacy `{ error }` body) |
| 500 | application/json | internal_error — The request failed for a reason the caller cannot fix. (legacy `{ error }` body) |
PATCH /api/v1/repos/{id}
Operation customer.repos.update. Change whether a repository is paused, and what it picks.
A PARTIAL update: send only the fields you mean to change. An empty object is an accepted no-op answering `changed: false`, not a refusal — the published schema accepts it, so refusing it would make this contract disagree with the server about what is a valid body. An ABSENT field is left alone; a `null` `maxConcurrentRunsThisRepo` CLEARS the sub-cap, which is a real write rather than an absence. A sub-cap above the account cap is inert, not refused — the effective cap is the lower of the two. `changed: false` is a success: re-sending the current state is an accepted no-op. An eligibility label must already exist on the repository — this operation does not create it, and a missing label makes pickup find nothing without an error. A label spelled like a label-mapping value is refused. Read the stored pickup settings back with the repo config read; this response carries the repository's own fields only.
Security: apiKey
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes |
Request body
| field | type | required | description |
|---|---|---|---|
paused | boolean | no | Whether this repository is paused. A paused repository is not dispatched. |
orderStrategy | "oldest-first" | "newest-first" | no | The direction the picker walks a tier. Built from the same `@descant/types` constant the runtime narrows against, so the contract and the picker cannot disagree about the vocabulary. |
maxConcurrentRunsThisRepo | integer | null | no | This repository's concurrency sub-cap. `null` CLEARS it, which is a real write and not an absence — the account cap alone then governs. The effective cap is `min(account, repo)`, so a value ABOVE the account cap is INERT rather than rejected: it will not raise anything. The upper bound is the column's own storage range, not a product ceiling. |
eligibilityLabel | string | no | The label an issue must carry to be eligible in `labeled` mode. Stored trimmed. THE LABEL MUST ALREADY EXIST ON THE REPOSITORY: this write does not create it, and a label the repository lacks makes pickup find nothing, with no error. Refused: empty, a `P<digits>` priority label, a comma, `blocked` or `waiting`, or a spelling a label-mapping value already uses. |
eligibilityMode | "labeled" | "all-issues" | no | Which issues are candidates at all: `labeled` requires the eligibility label, `all-issues` considers every open issue. Switching to `all-issues` keeps the stored label, so switching back restores it. |
Response
| field | type | required | description |
|---|---|---|---|
repo | object | yes | |
repo.id | string | yes | |
repo.owner | string | yes | |
repo.repo | string | yes | |
repo.onboardedStatus | string | yes | |
repo.paused | boolean | yes | |
changed | boolean | yes | Whether this request actually moved anything. FALSE is a success, not a refusal: re-sending the state a repository is already in is an accepted no-op, and the audit trail records the intent either way. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/json | invalid_body — The request body failed validation. (legacy `{ error }` body) |
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but its scopes do not include the one this operation requires (the `security` entry names it). Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/json | not_found — No such resource for this caller. (legacy `{ error }` body) |
| 429 | application/json | rate_limited — Too many requests for this credential or source. (legacy `{ error }` body) |
| 500 | application/json | internal_error — The request failed for a reason the caller cannot fix. (legacy `{ error }` body) |
GET /api/v1/repos/{id}/settings-audit
Operation customer.repos.settings-audit. Read one repository's recorded settings intents, newest first.
Who paused or resumed this repository, when, and whether the flip changed anything — readable by a key-holding program without a browser session. System pauses appear alongside operator ones, told apart by `actor`. A FIXED WINDOW of the 50 newest rows with no paging, so an empty `entries` is NOT proof the repository was never paused: it means nothing was recorded within that window. This is not a `paused` state oracle; the current pause reason is on the poller-status read. A malformed `id` answers `not_found`, exactly as an unknown or unreadable one does — never a validation error, so a caller cannot use the status to learn which ids are well-formed.
Security: apiKey
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes |
Response
| field | type | required | description |
|---|---|---|---|
entries | array of object | yes | |
entries[].id | string | yes | |
entries[].actor | string | yes | Who recorded the intent, as an OPAQUE string. The write side owns this vocabulary and a reader must not branch on its prefix shape. System pauses appear here alongside operator ones — the scheduler's evidence-driven auto-pause appends its own row actored `service:scheduler-autopause` — and `actor` is what tells them apart. |
entries[].action | string | yes | The intent recorded, as the write side names it. |
entries[].changed | boolean | yes | Whether this intent was a REAL change rather than a no-op re-assertion of the state already held. |
entries[].detail | object | null | yes | The row's detail, crossing the wire VERBATIM. ALWAYS PRESENT: a pause or resume carries its field transition; a `detach` records `null`, and that explicit null IS the persisted state — never an absence, never collapsed or dropped. The values are unbounded on purpose: the contract does not own this shape. |
entries[].createdAt | string | yes |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but its scopes do not include the one this operation requires (the `security` entry names it). Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/json | not_found — No such resource for this caller. (legacy `{ error }` body) |
| 429 | application/json | rate_limited — Too many requests for this credential or source. (legacy `{ error }` body) |
| 500 | application/json | internal_error — The request failed for a reason the caller cannot fix. (legacy `{ error }` body) |
GET /api/v1/repos/{id}/poller-status
Operation customer.repos.poller-status. Read why this repository is or is not being polled.
The read that answers 'why is nothing happening'. `status` is a discriminated union over SIXTEEN causes, because 'not polling' has four different remedies and a boolean loses the difference: a repository HELD on its candidates, a worker that never consulted the ticket source, a billing pause, and a page already filed by earlier runs all look the same through `paused: true`. Branch on `status.kind`. `repo.paused` and `billing.paused` are the raw flags beneath that reading, and `lastPoll` is the stored tick beneath both. A malformed `id` answers `not_found`, exactly as an unknown or unreadable one does — never a validation error, so a caller cannot use the status to learn which ids are well-formed.
Security: apiKey
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes |
Response
| field | type | required | description |
|---|---|---|---|
status | object | yes | |
status.kind | "never-polled" | yes | |
status.kind | "no-eligible-issues" | yes | |
status.lastPolledAt | string | yes | |
status.outcomeAt | string | null | yes | When this outcome was reached. On a HELD kind it carries held-since; on `pickup-not-wired`, stopped-since; on `page-self-filed-only`, stood-since. `null` when the outcome carries no such instant. |
status.kind | "page-self-filed-only" | yes | |
status.lastPolledAt | string | yes | |
status.outcomeAt | string | null | yes | When this outcome was reached. On a HELD kind it carries held-since; on `pickup-not-wired`, stopped-since; on `page-self-filed-only`, stood-since. `null` when the outcome carries no such instant. |
status.kind | "not-onboarded" | yes | |
status.onboardedStatus | string | yes | |
status.kind | "billing-paused" | yes | |
status.reason | "balance" | "cap" | "operator" | null | yes | `balance`, `cap` or `operator`. `null` means the reason was absent OR was a value this deployment does not recognise — the handler degrades an unknown reason to null rather than leaking it, so null is 'paused, reason not established', never 'not paused'. |
status.kind | "paused-manual" | yes | |
status.lastPolledAt | string | null | yes | |
status.kind | "paused-auto" | yes | |
status.consecutiveFailures | integer | yes | |
status.lastErrorClass | "rate-limited" | "auth" | "other" | null | yes | |
status.lastPolledAt | string | null | yes | |
status.kind | "held-page-exhausted" | yes | |
status.lastPolledAt | string | yes | |
status.outcomeAt | string | null | yes | When this outcome was reached. On a HELD kind it carries held-since; on `pickup-not-wired`, stopped-since; on `page-self-filed-only`, stood-since. `null` when the outcome carries no such instant. |
status.kind | "held-walk-stopped" | yes | |
status.lastPolledAt | string | yes | |
status.outcomeAt | string | null | yes | When this outcome was reached. On a HELD kind it carries held-since; on `pickup-not-wired`, stopped-since; on `page-self-filed-only`, stood-since. `null` when the outcome carries no such instant. |
status.kind | "pickup-not-wired" | yes | |
status.lastPolledAt | string | yes | |
status.outcomeAt | string | null | yes | When this outcome was reached. On a HELD kind it carries held-since; on `pickup-not-wired`, stopped-since; on `page-self-filed-only`, stood-since. `null` when the outcome carries no such instant. |
status.kind | "seeded" | yes | |
status.runId | string | null | yes | |
status.lastPolledAt | string | yes | |
status.outcomeAt | string | null | yes | When this outcome was reached. On a HELD kind it carries held-since; on `pickup-not-wired`, stopped-since; on `page-self-filed-only`, stood-since. `null` when the outcome carries no such instant. |
status.kind | "seeded-waiting-slot" | yes | |
status.lastPolledAt | string | yes | |
status.outcomeAt | string | null | yes | When this outcome was reached. On a HELD kind it carries held-since; on `pickup-not-wired`, stopped-since; on `page-self-filed-only`, stood-since. `null` when the outcome carries no such instant. |
status.kind | "invalid-order-query" | yes | |
status.lastPolledAt | string | yes | |
status.outcomeAt | string | null | yes | When this outcome was reached. On a HELD kind it carries held-since; on `pickup-not-wired`, stopped-since; on `page-self-filed-only`, stood-since. `null` when the outcome carries no such instant. |
status.kind | "error" | yes | |
status.errorClass | "rate-limited" | "auth" | "other" | null | yes | |
status.retriable | boolean | yes | |
status.consecutiveFailures | integer | yes | |
status.lastPolledAt | string | yes | |
status.outcomeAt | string | null | yes | When this outcome was reached. On a HELD kind it carries held-since; on `pickup-not-wired`, stopped-since; on `page-self-filed-only`, stood-since. `null` when the outcome carries no such instant. |
status.kind | "skipped" | yes | |
status.reason | string | yes | |
status.lastPolledAt | string | yes | |
status.outcomeAt | string | null | yes | When this outcome was reached. On a HELD kind it carries held-since; on `pickup-not-wired`, stopped-since; on `page-self-filed-only`, stood-since. `null` when the outcome carries no such instant. |
status.kind | "unknown-outcome" | yes | |
status.raw | string | null | yes | |
status.lastPolledAt | string | yes | |
lastPoll | object | yes | The last tick's stored fields, beneath the classification. Use `status` unless you need what was persisted. |
lastPoll.polledAt | string | null | yes | |
lastPoll.outcome | string | null | yes | The RAW stored outcome, unclassified. `status` is the reading of it. |
lastPoll.errorClass | string | null | yes | The raw stored error class, unnarrowed — unlike `status.errorClass`. |
lastPoll.seededRunId | string | null | yes | |
lastPoll.outcomeAt | string | null | yes | |
lastPoll.consecutiveTicketSourceUnavailableCount | integer | yes | |
repo | object | yes | |
repo.onboardedStatus | string | yes | |
repo.paused | boolean | yes | |
billing | object | yes | |
billing.paused | boolean | yes | |
billing.reason | "balance" | "cap" | "operator" | null | yes | Why billing paused this tenant, from the closed set the handler narrows to. `null` is 'paused, reason not established' — absent or unrecognised — never 'not paused'. |
billing.degraded | boolean | yes | Whether the billing read itself was degraded — so `paused` and `reason` may be stale rather than wrong. |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but its scopes do not include the one this operation requires (the `security` entry names it). Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/json | not_found — No such resource for this caller. (legacy `{ error }` body) |
| 429 | application/json | rate_limited — Too many requests for this credential or source. (legacy `{ error }` body) |
| 500 | application/json | internal_error — The request failed for a reason the caller cannot fix. (legacy `{ error }` body) |
GET /api/v1/repos/{id}/config
Operation customer.repos.config. Read what this repository is configured to pick, and in what order.
The rule behind the other two repo reads: which issues are candidates, which label makes one eligible, whether assigned issues are excluded, and the order the picker walks. Values are EFFECTIVE — the defaults are composed in when no row is stored, and no flag distinguishes the two, because they behave identically. `effectiveOrderQueries` is derived and must never be written back. A malformed `id` answers `not_found`, exactly as an unknown or unreadable one does.
Security: apiKey
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes |
Response
| field | type | required | description |
|---|---|---|---|
config | object | yes | |
config.eligibilityLabel | string | yes | The label an issue must carry to be eligible in `labeled` mode. Not consulted in `all-issues` mode. |
config.eligibilityMode | "labeled" | "all-issues" | yes | Which issues are candidates at all. `labeled` requires the eligibility label; `all-issues` considers every open issue, and is why the diagnostics read reports no label-bucketed counts in that mode. |
config.excludeAssigned | boolean | yes | Whether assigned issues are excluded. NOT durable operator policy: this is an AUTO-MANAGED recovery knob the reconciler can flip and restore without any operator write, so `false` must not be read as 'someone chose this'. |
config.orderStrategy | "oldest-first" | "newest-first" | yes | The direction the picker walks a tier: oldest issue first, or newest first. |
config.orderQueries | array of string | null | yes | The persisted walk, verbatim, or `null` meaning the mode's default walk. `null` is the common case and is not an error. |
config.effectiveOrderQueries | array of string | yes | DERIVED, NOT STORED — what the picker actually walks: `orderQueries` when set, otherwise the mode-aware default (the P0-P3 tiers plus the unlabeled final tier). Never write this back as configuration; `orderQueries` is the field that is set. |
config.autoMergeEnabled | boolean | yes | Whether a run's pull request is merged automatically once it is green. |
config.commitPlanDocument | boolean | yes | Whether runs also commit the plan document into the repository. |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but its scopes do not include the one this operation requires (the `security` entry names it). Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/json | not_found — No such resource for this caller. (legacy `{ error }` body) |
| 429 | application/json | rate_limited — Too many requests for this credential or source. (legacy `{ error }` body) |
| 500 | application/json | internal_error — The request failed for a reason the caller cannot fix. (legacy `{ error }` body) |
GET /api/v1/repos/{id}/issues
Operation customer.repos.issues.list. List a repository's open issues, as the enqueue picker sees them.
The issues a run can be started for, read through this deployment's author App on the repository the path names — the same listing the console's picker renders. `configured: false` is a SUCCESS and means this deployment cannot enumerate (no author-App credentials, or this repository's author installation never landed); enqueue still works with an issue number you supply, so branch on the field rather than on the status. A malformed id, an unknown id and another tenant's id are one indistinguishable `not_found`, answered with no upstream call. Upstream failures are classified rather than proxied: `upstream_rate_limited` carries its interval and is worth retrying, `upstream_auth` is this deployment's credential and not yours to fix.
Security: apiKey
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes | The `tenant_repos` row, as `customer.repos.list` returns it. |
Response
| field | type | required | description |
|---|---|---|---|
configured | false | yes | |
configured | true | yes | |
issues | array of object | yes | |
issues[].number | integer | yes | The issue number as the provider names it — what an enqueue takes. |
issues[].title | string | yes | |
issues[].labels | array of string | yes | The issue's labels, for a caller filtering on the eligibility label before it enqueues. |
issues[].bodySnippet | string | yes | The opening of the issue body, enough to recognise it in a list. Never the whole body. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_path — A path parameter failed validation. |
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but its scopes do not include the one this operation requires (the `security` entry names it). Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/problem+json | not_found — No such resource for this caller. |
| 429 | application/json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
| 502 | application/problem+json | upstream_auth — An upstream service rejected this deployment's credential. |
| 503 | application/problem+json | upstream_rate_limited — An upstream service is rate limiting us; retry after the stated interval. / upstream_timeout — An upstream service did not answer in time. / upstream_unavailable — A service this operation depends on did not answer. |
GET /api/v1/repos/{id}/labels
Operation customer.repos.labels.list. List a repository's label names, as the eligibility-label setting reads them.
The label NAMES this repository carries — what `customer.repos.update` stores as the eligibility label, and what the poller matches an issue against, so this is the read that makes that setting writable without guessing. `configured: false` is a SUCCESS on the same terms as the issue picker: setting a label by name still works when enumeration is unavailable. The `not_found` and upstream-classification rules are that operation's; see it.
Security: apiKey
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes | The `tenant_repos` row, as `customer.repos.list` returns it. |
Response
| field | type | required | description |
|---|---|---|---|
configured | false | yes | |
configured | true | yes | |
labels | array of string | yes |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_path — A path parameter failed validation. |
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but its scopes do not include the one this operation requires (the `security` entry names it). Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/problem+json | not_found — No such resource for this caller. |
| 429 | application/json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
| 502 | application/problem+json | upstream_auth — An upstream service rejected this deployment's credential. |
| 503 | application/problem+json | upstream_rate_limited — An upstream service is rate limiting us; retry after the stated interval. / upstream_timeout — An upstream service did not answer in time. / upstream_unavailable — A service this operation depends on did not answer. |
GET /api/v1/repos/{id}/poller-diagnostics
Operation customer.repos.poller-diagnostics. Explain why this repository's issues would or would not be picked.
A LIVE read: one provider call per request, never cached. Answers what the picker would do right now — how many issues carry the eligibility label, how many would be picked, and which buckets hold the rest. TWO of the three success shapes report NO counts (`configured: false`, and `mode: "all-issues"`), because neither can be measured in label buckets and a fabricated zero would read as "nothing is eligible" rather than "not measured". Branch on `configured`, then on `mode`. Counts are bounded by one page: check `truncated` before reading any of them as a repository total, and by the DEFAULT tier semantics: when `customOrdering` is true the picker walks the repository's own queries and the buckets describe the default walk rather than what it would pick. A malformed `id` answers `not_found`, exactly as an unknown or unreadable one does.
Security: apiKey
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes |
Response
| field | type | required | description |
|---|---|---|---|
configured | false | yes | This deployment has no author-App credentials, or this repository's author installation never completed. No counts exist, and none are fabricated. |
configured | true | yes | |
mode | "all-issues" | yes | The repository picks from ALL issues, so label-bucketed counts would not describe what it picks. No counts are reported, deliberately. |
configured | true | yes | |
mode | "labeled" | yes | |
diagnostics | object | yes | |
diagnostics.eligibilityLabel | string | yes | The configured label an issue must carry to be eligible. |
diagnostics.assignmentFilter | "exclude-assigned" | "bot-only" | yes | How the effective filter treats assignment. `exclude-assigned` is the steady state. `bot-only` is RECOVERY mode, and it is narrower than the name suggests: ONLY the bot's own stranded issues qualify, not issues with any assignee. |
diagnostics.totalEligibleLabeled | integer | yes | Open issues carrying the eligibility label. Bounded by one page — see `truncated`. |
diagnostics.wouldQualify | integer | yes | Issues the tier walk would pick under the effective filter. |
diagnostics.noPriorityLabel | integer | yes | Pass the assignment filter but carry no active P0-P3 label. Under the DEFAULT walk they are served by the unlabeled final tier after every priority tier, so ranked last rather than excluded. When `customOrdering` is true this count does NOT describe what the picker does: the bucketing is computed against the default tier semantics either way, while the picker walks the repository's own queries — an issue counted here may match one of those rows, and one counted in `wouldQualify` may match none. Read `customOrdering` first, and treat every count as default-walk semantics when it is true. |
diagnostics.staleRawTier | integer | yes | STRANDED until relabeled, and the one bucket that names its own remedy. These carry a reserved raw `P0`-`P3` spelling whose tier is mapped elsewhere, so they are excluded from the tier legs AND from the unlabeled tail. Always 0 under the identity taxonomy. |
diagnostics.assignmentExcluded | integer | yes | Excluded by the assignment filter, whether or not they carry a tier label — assignment gates the tier legs and the unlabeled tail alike. |
diagnostics.truncated | boolean | yes | TRUE means the page came back full, so every count above covers only the first page of issues by recency and NOT the repository. A truncated count presented as a total is the one way this read misleads. |
diagnostics.customOrdering | boolean | yes | TRUE when the repository configures an explicit `order_queries` array. The picker then walks exactly those rows with no implicit unlabeled tail. It is a CONFIG-LEVEL FACT rather than a classification bucket — the counts above are computed against the default tier semantics regardless — so when this is true, read them as 'what the default walk would do', not as a prediction of this repository's picker. |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but its scopes do not include the one this operation requires (the `security` entry names it). Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/json | not_found — No such resource for this caller. (legacy `{ error }` body) |
| 429 | application/json | rate_limited — Too many requests for this credential or source. (legacy `{ error }` body) |
| 502 | application/json | upstream_auth — An upstream service rejected this deployment's credential. (legacy `{ error }` body) |
| 503 | application/json | upstream_rate_limited — An upstream service is rate limiting us; retry after the stated interval. / upstream_timeout — An upstream service did not answer in time. / upstream_unavailable — A service this operation depends on did not answer. (legacy `{ error }` body) |
GET /api/v1/audit
Operation customer.audit.list. List this tenant's authenticated API calls, newest first.
One row per authenticated call against this tenant, including the ones that did not succeed: a 403 for a key outside its scope, and a 500 for a call that failed after it was admitted. A 429 is never here — the only one this surface answers is the pre-authentication per-source bound, which refuses before a credential is read and so has no tenant to attribute a row to. `principal` says who: `api-key:<keyId>` when one of this tenant's credentials called, `user:<userId>` when a signed-in person exercised the same capability from the console — a key minted on the settings page is the same capability as a mint over this API, and both leave a row. A call with no tenant to attribute is not recorded, so an empty page does not mean nothing was tried. Paged by an opaque page token; `principal` narrows to one caller. `invalid_query` covers a malformed page token — one this server did not mint, or one minted by another paged read — which is refused rather than read as `start over`.
Security: apiKey
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
limit | query | integer | no | Page size. Defaults to 50, clamped to 200; the applied value is echoed. |
pageToken | query | string | no | Opaque, server-minted. Pass the previous response's `nextPageToken` verbatim; never construct one. |
principal | query | string | no | Narrow the page to one credential's calls. |
Response
| field | type | required | description |
|---|---|---|---|
items | array of object | yes | |
items[].id | string | yes | |
items[].principal | string | yes | Who made the call, as an OPAQUE string the write side owns; do not branch on its prefix shape. A live row reads `api-key:<keyId>`, or `user:<userId>` for a signed-in person exercising the same capability from the console; a row whose subject has been erased reads a redaction marker instead. |
items[].operationId | string | null | yes | The operation the route serves, or `null` for a route the contract has not declared yet. |
items[].method | string | yes | The HTTP method, upper case. |
items[].path | string | yes | The request path without its query string. A segment may be a resource id, never prose. |
items[].scope | string | yes | The scope the operation required, `<family>:<read|write>`. |
items[].status | integer | yes | The HTTP status answered. |
items[].outcome | "ok" | "refused" | "failed" | yes | Derived from `status`: below 400 ok, 4xx refused, 5xx failed. |
items[].requestId | string | yes | The `X-Request-Id` the response carried. |
items[].createdAt | string | yes | |
nextPageToken | string | null | yes | `null` when this is the last page. |
limit | integer | yes | The page size actually applied, after clamping. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_query — A query parameter failed validation. |
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but its scopes do not include the one this operation requires (the `security` entry names it). Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 429 | application/json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
POST /api/v1/mcp
Operation customer.mcp.call. Speak MCP to this tenant's API.
The Model Context Protocol endpoint. An agent connects an MCP client here and drives the operations in this document as typed tools, with this key's own credential. THE SCOPE THIS REQUIRES IS THE ENVELOPE, NOT THE CONTENTS: holding it means a key may open the transport, and every tool call inside is still checked against the scope its own operation requires — so a key with only read scopes can open this and still cannot write through it. Requests are stateless: each one is served independently and nothing is held between them.
Security: apiKey
Response
An empty object.
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but its scopes do not include the one this operation requires (the `security` entry names it). Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 429 | application/problem+json | rate_limited — Too many requests for this credential or source. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
GET /api/v1/meta/operations
Operation customer.meta.operations. List the customer operation ids and the contract version this deployment serves.
Unauthenticated, and answered from the registry alone — no database, no credential, no tenant. What a client uses to learn whether it is behind: `descant version --check` diffs its bundled registry against this list and names the operations it cannot reach. IT COVERS THE CUSTOMER AUDIENCE ONLY, and says so in `audiences`. Those ids are what the OpenAPI document already publishes to anyone; the admin ids are not served here, because an unauthenticated caller is the person the help-listing ruling (#15338) protects and this would be the same disclosure on a wider channel. A caller holding an admin bearer is not that person and is served by its own operation. A client must restrict its own side of the diff to `audiences`, or every operation of an uncovered audience reads as one the deployment lacks. Carries no shapes or schemas — the OpenAPI document is the contract, this is its table of contents with a version stamp. Bounded by the per-source limiter every route under `/api/v1` pays.
Security: none
Response
| field | type | required | description |
|---|---|---|---|
contractVersion | string | yes | `CONTRACT_VERSION` of the deployed registry — the same value a binary built from it reports as `contract`. |
openapiVersion | string | yes | The OpenAPI version the document is written to. |
audiences | array of "customer" | "admin" | yes | WHICH AUDIENCES THIS LISTING COVERS, and therefore what a diff against it can conclude. This route answers `["customer"]`; an operation of an audience NOT named here is absent because it was not listed, never because the deployment does not serve it. A client must restrict its own side of the comparison to these audiences, or every unlisted id reads as one the deployment lacks. |
operations | array of object | yes | Every operation of the covered audiences that the deployed registry serves, in published order. A client diffs its own bundled registry — restricted to `audiences` — against this list; an id here and not there is one it cannot reach. |
operations[].id | string | yes | The operation id, exactly as the OpenAPI document and the CLI's `version --spec` spell it. |
operations[].method | string | yes | The HTTP method, lowercase. |
operations[].path | string | yes | The route template, with `{param}` placeholders. |
Errors
| status | body | meaning |
|---|---|---|
| 429 | application/json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
POST /api/v1/auth/device
Operation customer.auth.device.start. Begin a browser login and get the code the person will approve.
Unauthenticated, because it is what a caller with no credential uses to get one. Answers a secret `deviceCode` the client keeps, a short `userCode` a person reads, and the URL that shows the approval page. Nothing is minted here: the authorization sits pending until a signed-in person approves it, and it stops being redeemable after `expiresIn` seconds whether or not anyone does. Bounded by the per-IP limiter every route under `/api/v1` pays.
Security: none
Request body
| field | type | required | description |
|---|---|---|---|
clientName | string | no | What to call this client on the approval page. Display only, and shown as untrusted text. |
Response
| field | type | required | description |
|---|---|---|---|
deviceCode | string | yes | The secret half of the handshake: what the poll presents. Shown to nobody, stored only as a hash, and worth nothing once the key has been delivered against it. |
userCode | string | yes | The short code a person reads and confirms, as `XXXX-XXXX`. Matched case-insensitively with the dash optional. |
verificationUrl | string | yes | Where the person approves. Carries the user code already filled in, so the common path is a click rather than typing. |
expiresIn | integer | yes | Seconds until this authorization stops being redeemable. |
interval | integer | yes | Seconds the client should wait between polls. Polling faster answers `slow_down`. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_body — The request body failed validation. |
| 429 | application/json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
POST /api/v1/auth/device/token
Operation customer.auth.device.token. Poll a browser login, and collect the key once it is approved.
Answers 200 with a `status` discriminant in every case a caller will meet: `pending` while nobody has decided, `slow_down` when the client polls faster than the `interval` it was given, `approved` carrying the key EXACTLY ONCE, `denied` when the person refused, and `expired` when the window closed. `expired` is also the answer for a `deviceCode` this server never minted and for one whose key has already been collected — deliberately one indistinguishable outcome, so a caller cannot use this endpoint to learn whether a code exists. Never 4xx for `pending`: a poll that has not been decided yet is the normal state of this operation, not an error.
Security: none
Request body
| field | type | required | description |
|---|---|---|---|
deviceCode | string | yes |
Response
| field | type | required | description |
|---|---|---|---|
status | "pending" | yes | |
interval | integer | yes | Seconds to wait before the next poll. |
status | "slow_down" | yes | |
interval | integer | yes | The client polled faster than `interval`. Not a refusal: wait this long and poll again. |
status | "approved" | yes | |
key | string | yes | The tenant API key (`dsc_…`), delivered once. A second poll answers `expired`. |
keyId | string | yes | The key's id, so a person can find this key in the dashboard's list and revoke it. |
tenantSlug | string | yes | Which organisation the person approved for. The CLI prints it so a two-tenant operator can see which one they got. |
scopes | array of string | yes | What the approved key may exercise. Chosen by the person on the approval page, never by the client. |
expiresAt | string | null | yes | `null` never expires. |
status | "denied" | yes | |
status | "expired" | yes |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_body — The request body failed validation. |
| 429 | application/json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
GET /api/v1/keys
Operation customer.keys.list. List this tenant's API keys, newest first.
Every key the tenant has minted, active and revoked, newest first — a revoke is a soft one, so a revoked key stays in this list with `revokedAt` set. Never the secret: only the display prefix. Paged by an opaque page token, because a key holding `keys:write` mints without a person and nothing bounds the row count; follow `nextPageToken` until it is `null`. `invalid_query` covers a malformed page token — one this server did not mint, or one minted by another paged read — which is refused rather than read as `start over`.
Security: apiKey
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
limit | query | integer | no | Page size. Defaults to 50, clamped to 200; the applied value is echoed. |
pageToken | query | string | no | Opaque, server-minted. Pass the previous response's `nextPageToken` verbatim; never construct one. |
Response
| field | type | required | description |
|---|---|---|---|
items | array of object | yes | |
items[].id | string | yes | |
items[].label | string | yes | |
items[].keyPrefix | string | yes | `dsc_` plus the first eight characters of the secret: how a key is named after it is minted. |
items[].scopes | array of string | yes | |
items[].expiresAt | string | null | yes | `null` never expires. |
items[].createdAt | string | yes | |
items[].lastUsedAt | string | null | yes | |
items[].revokedAt | string | null | yes | `null` while the key is active. |
nextPageToken | string | null | yes | `null` when this is the last page. |
limit | integer | yes | The page size actually applied, after clamping. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_query — A query parameter failed validation. |
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but its scopes do not include the one this operation requires (the `security` entry names it). Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 429 | application/json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
POST /api/v1/keys
Operation customer.keys.create. Mint a key holding a subset of this key's scopes.
Mints a key for the same tenant, attributed to the same creating account as the minting key. The requested scopes must be a subset of the minting key's (a key cannot widen itself); `conflict` names the scope that is not held. A minting key with no creating account on record (a legacy key whose creator was erased) is refused with `conflict` rather than minting a child nothing is attributed to. The secret appears in this response and nowhere else.
Security: apiKey
Request body
| field | type | required | description |
|---|---|---|---|
label | string | yes | |
scopes | array of string | yes | The scopes the new key holds. Must be a subset of the minting key's own — a key cannot widen itself. |
expiresAt | string | no | When the key stops authenticating. Omit for a key that does not expire. Must be in the future. |
Response
| field | type | required | description |
|---|---|---|---|
key | object | yes | |
key.id | string | yes | |
key.label | string | yes | |
key.keyPrefix | string | yes | `dsc_` plus the first eight characters of the secret: how a key is named after it is minted. |
key.scopes | array of string | yes | |
key.expiresAt | string | null | yes | `null` never expires. |
key.createdAt | string | yes | |
key.lastUsedAt | string | null | yes | |
key.revokedAt | string | null | yes | `null` while the key is active. |
key.rawKey | string | yes | The secret, shown exactly once. It is stored hashed and cannot be recovered; a caller that loses it mints a new key. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_body — The request body failed validation. |
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but its scopes do not include the one this operation requires (the `security` entry names it). Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 409 | application/problem+json | conflict — The resource is not in a state that admits this. |
| 429 | application/json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
DELETE /api/v1/keys/{keyId}
Operation customer.keys.revoke. Revoke one of this tenant's keys.
Soft-revokes: the row stays so the list can show it, and the key stops authenticating at once. A repeat reports `already-revoked`. A key id that belongs to another tenant, or to nothing, is one indistinguishable `not_found`. A key may revoke ITSELF; the call that does so is its last.
Security: apiKey
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
keyId | path | string | yes |
Response
| field | type | required | description |
|---|---|---|---|
key | object | yes | |
key.id | string | yes | |
key.outcome | "revoked" | "already-revoked" | yes | `already-revoked` on a repeat: the key was not active, so nothing changed. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_path — A path parameter failed validation. |
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but its scopes do not include the one this operation requires (the `security` entry names it). Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/problem+json | not_found — No such resource for this caller. |
| 429 | application/json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
POST /api/v1/keys/{keyId}/rotate
Operation customer.keys.rotate. Replace a key with a successor; the old one lives on for a grace window.
Mints a successor holding the target's label and scopes, attributed to the minting key's creating account, and sets the target to stop authenticating at the end of the grace window (`graceSeconds`: default one hour, `0` at once) or at its own expiry, whichever is earlier — an expiry is never extended. A rotation is a mint, so the minting key must hold every scope the target does; `conflict` names the scope that is not held. A target already revoked is `conflict` too: it has nothing to rotate. So is a target holding a scope the server answering does not recognise — rotating it would drop that scope from the successor permanently, so it is refused rather than silently narrowed; this can happen briefly while a deployment is rolling, and a retry clears it. A key may rotate itself. The successor's secret appears in this response and nowhere else.
Security: apiKey
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
keyId | path | string | yes |
Request body
| field | type | required | description |
|---|---|---|---|
graceSeconds | integer | no | How long the retired key keeps authenticating once the successor exists, in seconds. `0` retires it at once; the default is one hour; at most one day. |
expiresAt | string | no | When the SUCCESSOR stops authenticating. Omit for a successor that does not expire; the target's own expiry is not inherited. Must be in the future. |
Response
| field | type | required | description |
|---|---|---|---|
key | object | yes | |
key.id | string | yes | |
key.label | string | yes | |
key.keyPrefix | string | yes | `dsc_` plus the first eight characters of the secret: how a key is named after it is minted. |
key.scopes | array of string | yes | |
key.expiresAt | string | null | yes | `null` never expires. |
key.createdAt | string | yes | |
key.lastUsedAt | string | null | yes | |
key.revokedAt | string | null | yes | `null` while the key is active. |
key.rawKey | string | yes | The secret, shown exactly once. It is stored hashed and cannot be recovered; a caller that loses it mints a new key. |
retired | object | yes | |
retired.id | string | yes | |
retired.expiresAt | string | yes | When the retired key stops authenticating: the earlier of its own expiry and the end of the grace window. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_path — A path parameter failed validation. / invalid_body — The request body failed validation. |
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but its scopes do not include the one this operation requires (the `security` entry names it). Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/problem+json | not_found — No such resource for this caller. |
| 409 | application/problem+json | conflict — The resource is not in a state that admits this. |
| 429 | application/json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
GET /api/v1/invites
Operation customer.invites.list. List the key's own account's invites, with the budget and referral status.
The invites belonging to the account that CREATED THIS KEY — not the tenant's current owner; see the family note, because which key you use decides whose budget you read. Metadata only: the invite code and its hash never appear here, only in the create response. A key with no creating account (minted before attribution, or one whose creator was erased) resolves to no account and answers `not_found`, the same answer as asking about somebody else's.
Security: apiKey
Response
| field | type | required | description |
|---|---|---|---|
invites | array of object | yes | METADATA ONLY — never the code or its hash. A list is re-readable, and a secret that can be re-read is not reveal-once. |
invites[].id | string | yes | The invite's id — what `customer.invites.revoke` takes. |
invites[].status | string | yes | Open by design, as the run state is: statuses are added as the invite lifecycle grows. |
invites[].createdAt | string | yes | |
invites[].redeemedAt | string | null | yes | `null` while the invite is unredeemed. |
budget | object | yes | |
budget.unlimited | true | yes | |
budget.remaining | null | yes | |
budget.unlimited | false | yes | |
budget.remaining | integer | yes | |
referralStatus | object | yes | |
referralStatus.invitesConverted | integer | yes | |
referralStatus.creditsEarnedMicros | string | yes | Micros as a decimal STRING, not a number: the value is exact and JSON numbers are not, which is the convention this API already uses for exact ids. |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but its scopes do not include the one this operation requires (the `security` entry names it). Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/json | not_found — No such resource for this caller. (legacy `{ error }` body) |
| 429 | application/json | rate_limited — Too many requests for this credential or source. (legacy `{ error }` body) |
| 500 | application/json | internal_error — The request failed for a reason the caller cannot fix. (legacy `{ error }` body) |
POST /api/v1/invites
Operation customer.invites.create. Mint one invite, and reveal its code once.
REVEAL-ONCE: the `code` and `path` exist in this response and nowhere else. Surface the link and do not persist the code — no read can return it, and a lost code means minting another. The body carries no fields, so an ABSENT or empty body is the natural call and is read as `{}`; a body with an unknown field is refused rather than ignored. Spending the account's last slot answers `invite_limit_reached` — a well-formed request that a rule refused, which is why it is not the malformed-body code. It spends the CREATING account's budget, which is the binding that keeps one co-owner's key from spending another's.
Security: apiKey
Response
| field | type | required | description |
|---|---|---|---|
invite | object | yes | |
invite.id | string | yes | |
invite.code | string | yes | THE CODE, and this is the ONLY response that carries it. Surface it, hand it over, and do not persist it — nothing can return it again. |
invite.path | string | yes | The redemption path the code belongs to. |
budget | object | yes | The budget AFTER this mint, so a caller need not re-list to know what is left. |
budget.unlimited | true | yes | |
budget.remaining | null | yes | |
budget.unlimited | false | yes | |
budget.remaining | integer | yes |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/json | invalid_body — The request body failed validation. (legacy `{ error }` body) |
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but its scopes do not include the one this operation requires (the `security` entry names it). Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/json | not_found — No such resource for this caller. (legacy `{ error }` body) |
| 422 | application/json | invite_limit_reached — The tenant's invite limit is reached. (legacy `{ error }` body) |
| 429 | application/json | rate_limited — Too many requests for this credential or source. (legacy `{ error }` body) |
| 500 | application/json | internal_error — The request failed for a reason the caller cannot fix. (legacy `{ error }` body) |
DELETE /api/v1/invites/{id}
Operation customer.invites.revoke. Revoke one invite, reclaiming its budget slot.
Kills the link and returns the slot to the account's budget — the same primitive the dashboard's per-row Revoke uses, so the two surfaces cannot drift. A missing invite, an already-terminal one and ANOTHER ACCOUNT'S are one indistinguishable `not_found`: the underlying predicate is on the acting account, so nothing here discloses that an invite exists for somebody else. That also means a revoke is safe to repeat in the sense that matters — the second call answers `not_found` rather than doing something new — but it is not idempotent in the `Idempotency-Key` sense and does not claim to be.
Security: apiKey
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes | The invite id, as the list read returns it. |
Response
| field | type | required | description |
|---|---|---|---|
revoked | true | yes |
Errors
| status | body | meaning |
|---|---|---|
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but its scopes do not include the one this operation requires (the `security` entry names it). Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/json | not_found — No such resource for this caller. (legacy `{ error }` body) |
| 429 | application/json | rate_limited — Too many requests for this credential or source. (legacy `{ error }` body) |
| 500 | application/json | internal_error — The request failed for a reason the caller cannot fix. (legacy `{ error }` body) |
GET /api/v1/repos/{id}/grooming/candidates
Operation customer.repos.grooming.candidates. Read the relationships Descant would propose for this repository's backlog.
PROPOSALS WITH THEIR EVIDENCE, not edges: nothing exists until `customer.repos.grooming.dispatch` lands it, and each candidate names the phrase in the tenant's own issue body that suggested it, because a relationship asserted with no reason cannot be reviewed. Served from the local copy of the backlog rather than a live walk — a proposal is a proposed WRITE against the copy the caller is looking at, so it must be derived from that same copy. `asOf`, `truncated` and `capped` say how current and how complete that copy was. WHEN THE COPY CANNOT SERVE, this answers `upstream_unavailable` and NEVER an empty list: an empty list is what a working detector returns over a backlog with no relationships, and the two must not be spelled the same.
Security: apiKey
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes | The `tenant_repos` row, as `customer.repos.list` returns it. |
Response
| field | type | required | description |
|---|---|---|---|
candidates | array of object | yes | PROPOSALS, not edges. Nothing exists until `dispatch` lands it. |
candidates[].id | string | yes | The candidate's own id, derived from its content — what a caller cites when acting on it. |
candidates[].kind | "blocked-by" | "decomposed-from" | "duplicate-of" | "serialize-with" | "together-with" | yes | |
candidates[].from | string | yes | An issue, as the store names it — OPAQUE by design: the store compares references and never parses, reformats or resolves one, and this deployment spells a home-repository reference differently from a cross-repository one. So the only shape promised here is non-empty. A well-formed reference the document does not hold is not a malformed request: it answers `rejected` naming the reference, because only the document can know. |
candidates[].to | string | yes | An issue, as the store names it — OPAQUE by design: the store compares references and never parses, reformats or resolves one, and this deployment spells a home-repository reference differently from a cross-repository one. So the only shape promised here is non-empty. A well-formed reference the document does not hold is not a malformed request: it answers `rejected` naming the reference, because only the document can know. |
candidates[].evidence | array of object | yes | WHY the detector proposed this. `token` names the detector; `text` is the phrase from the tenant's own issue body that triggered it — free text, and the only free text this family publishes. It is the tenant's own backlog returned to that tenant, and without it a proposal is an assertion nobody can review. |
candidates[].evidence[].token | string | yes | |
candidates[].evidence[].text | string | yes | |
asOf | string | yes | When this copy was last known to have no unprocessed backlog. A candidate is only as current as the copy it was derived from. |
truncated | boolean | yes | The copy cut the backlog off, so the proposal set is over a partial view. |
capped | boolean | yes | The detector stopped at its own ceiling, so there may be proposals it never reached. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_path — A path parameter failed validation. |
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but its scopes do not include the one this operation requires (the `security` entry names it). Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/problem+json | not_found — No such resource for this caller. |
| 429 | application/json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
| 503 | application/problem+json | upstream_unavailable — A service this operation depends on did not answer. |
GET /api/v1/repos/{id}/grooming/document
Operation customer.repos.grooming.document. Read the relationship document this repository's issues currently encode.
The issues and the edges together, as the workspace renders them — what IS, where the candidates read is what COULD be. `cycleGraphIncomplete` is non-null when the cycle check could not see the whole graph: do not read 'no cycles' from an incomplete one. `encodingRefused` names issues whose relationships could not be written back into their bodies, so their edges are readable here and NOT persisted upstream — a distinction that decides whether another reader will see them. Mirror-served and typed-refusal on the same terms as the candidates read; see it.
Security: apiKey
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes | The `tenant_repos` row, as `customer.repos.list` returns it. |
Response
| field | type | required | description |
|---|---|---|---|
document | object | yes | The whole document — issues and edges together. Never a patch or a slice; see the family header. |
document.issues | array of object | yes | |
document.issues[].ref | string | yes | An issue, as the store names it — OPAQUE by design: the store compares references and never parses, reformats or resolves one, and this deployment spells a home-repository reference differently from a cross-repository one. So the only shape promised here is non-empty. A well-formed reference the document does not hold is not a malformed request: it answers `rejected` naming the reference, because only the document can know. |
document.issues[].title | string | yes | |
document.issues[].state | "open" | "closed" | yes | |
document.issues[].priority | 0 | 1 | 2 | 3 | no | The RESOLVED tier. See `priorityDisagreement` when the issue's two carriers disagree. |
document.issues[].url | string | no | |
document.issues[].updatedAt | string | yes | THE CONFLICT TOKEN. Send it back in `dispatch`'s `expected` as `ref=updatedAt`; an issue that moved since you read it answers `conflict` rather than overwriting an edit you never saw. |
document.issues[].order | object | null | yes | `null` on a closed issue: it holds no position. |
document.issues[].order.rank | integer | null | yes | `null` when the issue holds no rank. |
document.issues[].order.ready | boolean | yes | |
document.issues[].order.holds | array of object | yes | |
document.issues[].order.holds[].family | "graph" | "tracker" | yes | |
document.issues[].order.holds[].reason | string | yes | |
document.issues[].order.holds[].label | "claimed" | "assigned" | "filtered" | no | The runner's one-word chip for a hold it imposes. `tracker` holds only, and not all of them — a cut-short readiness verdict and a unit over its member cap carry none. |
document.issues[].priorityDisagreement | object | null | yes | Non-null only when the issue's label and its frontmatter name different tiers. Recorded rather than derived — by the time `priority` exists the two have been collapsed into one number. |
document.issues[].priorityDisagreement.usedLabel | string | yes | The tier label AS WRITTEN ON THE ISSUE, never the configured spelling. |
document.issues[].priorityDisagreement.ignoredPriority | integer | yes | The frontmatter tier that was set aside; the format resolves label-first. |
document.issues[].unit | array of string | yes | |
document.issues[].lead | string | null | yes | |
document.issues[].effectivePriority | 0 | 1 | 2 | 3 | null | yes | |
document.issues[].promotedBy | array of string | yes | |
document.edges | array of object | yes | |
document.edges[].id | string | yes | Derived from the edge's content rather than assigned, so the same relationship has the same id wherever it is read. |
document.edges[].kind | "blocked-by" | "decomposed-from" | "duplicate-of" | "serialize-with" | "together-with" | yes | |
document.edges[].from | string | yes | An issue, as the store names it — OPAQUE by design: the store compares references and never parses, reformats or resolves one, and this deployment spells a home-repository reference differently from a cross-repository one. So the only shape promised here is non-empty. A well-formed reference the document does not hold is not a malformed request: it answers `rejected` naming the reference, because only the document can know. |
document.edges[].to | string | yes | An issue, as the store names it — OPAQUE by design: the store compares references and never parses, reformats or resolves one, and this deployment spells a home-repository reference differently from a cross-repository one. So the only shape promised here is non-empty. A well-formed reference the document does not hold is not a malformed request: it answers `rejected` naming the reference, because only the document can know. |
asOf | string | yes | When this copy was last known to have no unprocessed backlog. |
truncated | boolean | yes | The open enumeration was CUT at the row cap — surfaced rather than laundered into a shorter backlog. |
cycleGraphIncomplete | string | null | yes | Non-null when the cycle check could not see the whole graph, naming why — a caller must not read 'no cycles' from an incomplete one. A write that could close a cycle is refused while this is set. |
encodingRefused | array of object | yes | Issues whose relationships could not be encoded back into their bodies. Their edges are readable here and are NOT persisted upstream. |
encodingRefused[].ref | string | yes | |
encodingRefused[].diagnostic | string | no | |
host | object | yes | What the HOST is doing right now — the runs in flight and the cap over them. An object, not a name: an earlier cut declared this a string and the strict schema rejected every real answer. |
host.concurrencyCap | integer | no | |
host.running | array of object | yes | |
host.running[].key | string | yes | The document key of the issue being worked — `owner/repo#N`. |
host.running[].phase | string | yes | The runner's phase word, in the operator's vocabulary. |
host.running[].startedAt | string | yes | |
host.running[].title | string | yes | |
host.running[].url | string | yes | |
host.truncated | boolean | yes | The open enumeration was cut, so the host's own counts are omitted rather than stated as totals. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_path — A path parameter failed validation. |
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but its scopes do not include the one this operation requires (the `security` entry names it). Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/problem+json | not_found — No such resource for this caller. |
| 429 | application/json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
| 503 | application/problem+json | upstream_unavailable — A service this operation depends on did not answer. |
POST /api/v1/repos/{id}/grooming/dispatch
Operation customer.repos.grooming.dispatch. Land one relationship edit in the issue bodies, fenced on what the caller read.
ONE mutation per call — create, delete, retype or flip — written into the GitHub ISSUE BODY through this repository's App installation. It does NOT seed a run and never reaches the runner, so no capacity answer applies. FENCED ON `expected`: issue ref to the `updatedAt` you hydrated with. An issue that moved since then answers `outcome: "conflict"` carrying the authoritative document, so a caller re-derives rather than overwriting an edit it never saw. WHEN AN ANSWER CARRIES A DOCUMENT IT IS THE WHOLE ONE, never a patch — `applied`, `unchanged` and `conflict` each carry it, and `unchanged` carries it precisely because 'nothing to do' is often said when your copy is stale and somebody else already created the edge. A `rejected` carries NO document: it carries a reason, and the `landed` flag if the write reached the issue before the failure. `rejected` means the write was refused upstream and nothing changed there — WITH ONE EXCEPTION that inverts it: a `rejected` carrying `landed: true` means the write DID land and only the local copy is behind, so drop the document and the `updatedAt` tokens you hold and re-read before your next edit. WHICH FIELDS GO WITH WHICH `op`, because the body is flat rather than a union: `create` takes `kind`, `from` and `to`; `delete` and `flip` take `edgeId`; `retype` takes `edgeId` and `nextKind`. `mutationId` is yours to mint and is required on every op. A field belonging to a different op is REFUSED rather than ignored — a body carrying both describes two edits and applying either is a guess — and a missing one answers `invalid_body` naming the field.
Security: apiKey
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes | The `tenant_repos` row, as `customer.repos.list` returns it. |
Request body
| field | type | required | description |
|---|---|---|---|
op | "create" | "delete" | "retype" | "flip" | yes | WHICH edit. The fields that go with it are listed in this operation's description and checked by the route. |
mutationId | string | yes | YOURS TO MINT, required on every op, and echoed in this deployment's logs so one edit can be traced end to end. It is NOT replay protection: nothing here deduplicates on it, and resending the same id performs the edit again. `expected` is what makes a replay safe — a stale stamp answers `conflict` rather than writing twice. |
kind | "blocked-by" | "decomposed-from" | "duplicate-of" | "serialize-with" | no | `create` only: the relationship to add. `together-with` is absent deliberately — the writer cannot splice it, so offering it would advertise a create that always fails. |
from | string | no | `create` only. |
to | string | no | `create` only. |
edgeId | string | no | `delete`, `retype` and `flip`: the relationship being changed, as the document returns it. |
nextKind | "blocked-by" | "decomposed-from" | "duplicate-of" | "serialize-with" | no | `retype` only: what the relationship becomes. Not `together-with`, for the reason `kind` gives — and a retype OUT of an existing `together-with` is refused by the writer too, which no enum here can say. |
expected | array of string | yes | THE CONFLICT FENCE, as `issueRef=updatedAt` entries — the stamps you hydrated with. An issue that moved upstream since then answers `conflict` with the authoritative document rather than overwriting an edit you never saw. Entries rather than a map because the flag generator has no spelling for a map; the route splits them on the first `=`. |
Response
| field | type | required | description |
|---|---|---|---|
outcome | "applied" | yes | |
document | object | yes | The whole document — issues and edges together. Never a patch or a slice; see the family header. |
document.issues | array of object | yes | |
document.issues[].ref | string | yes | An issue, as the store names it — OPAQUE by design: the store compares references and never parses, reformats or resolves one, and this deployment spells a home-repository reference differently from a cross-repository one. So the only shape promised here is non-empty. A well-formed reference the document does not hold is not a malformed request: it answers `rejected` naming the reference, because only the document can know. |
document.issues[].title | string | yes | |
document.issues[].state | "open" | "closed" | yes | |
document.issues[].priority | 0 | 1 | 2 | 3 | no | The RESOLVED tier. See `priorityDisagreement` when the issue's two carriers disagree. |
document.issues[].url | string | no | |
document.issues[].updatedAt | string | yes | THE CONFLICT TOKEN. Send it back in `dispatch`'s `expected` as `ref=updatedAt`; an issue that moved since you read it answers `conflict` rather than overwriting an edit you never saw. |
document.issues[].order | object | null | yes | `null` on a closed issue: it holds no position. |
document.issues[].order.rank | integer | null | yes | `null` when the issue holds no rank. |
document.issues[].order.ready | boolean | yes | |
document.issues[].order.holds | array of object | yes | |
document.issues[].order.holds[].family | "graph" | "tracker" | yes | |
document.issues[].order.holds[].reason | string | yes | |
document.issues[].order.holds[].label | "claimed" | "assigned" | "filtered" | no | The runner's one-word chip for a hold it imposes. `tracker` holds only, and not all of them — a cut-short readiness verdict and a unit over its member cap carry none. |
document.issues[].priorityDisagreement | object | null | yes | Non-null only when the issue's label and its frontmatter name different tiers. Recorded rather than derived — by the time `priority` exists the two have been collapsed into one number. |
document.issues[].priorityDisagreement.usedLabel | string | yes | The tier label AS WRITTEN ON THE ISSUE, never the configured spelling. |
document.issues[].priorityDisagreement.ignoredPriority | integer | yes | The frontmatter tier that was set aside; the format resolves label-first. |
document.issues[].unit | array of string | yes | |
document.issues[].lead | string | null | yes | |
document.issues[].effectivePriority | 0 | 1 | 2 | 3 | null | yes | |
document.issues[].promotedBy | array of string | yes | |
document.edges | array of object | yes | |
document.edges[].id | string | yes | Derived from the edge's content rather than assigned, so the same relationship has the same id wherever it is read. |
document.edges[].kind | "blocked-by" | "decomposed-from" | "duplicate-of" | "serialize-with" | "together-with" | yes | |
document.edges[].from | string | yes | An issue, as the store names it — OPAQUE by design: the store compares references and never parses, reformats or resolves one, and this deployment spells a home-repository reference differently from a cross-repository one. So the only shape promised here is non-empty. A well-formed reference the document does not hold is not a malformed request: it answers `rejected` naming the reference, because only the document can know. |
document.edges[].to | string | yes | An issue, as the store names it — OPAQUE by design: the store compares references and never parses, reformats or resolves one, and this deployment spells a home-repository reference differently from a cross-repository one. So the only shape promised here is non-empty. A well-formed reference the document does not hold is not a malformed request: it answers `rejected` naming the reference, because only the document can know. |
asOf | string | yes | When this copy was last known to have no unprocessed backlog. |
truncated | boolean | yes | The open enumeration was CUT at the row cap — surfaced rather than laundered into a shorter backlog. |
cycleGraphIncomplete | string | null | yes | Non-null when the cycle check could not see the whole graph, naming why — a caller must not read 'no cycles' from an incomplete one. A write that could close a cycle is refused while this is set. |
encodingRefused | array of object | yes | Issues whose relationships could not be encoded back into their bodies. Their edges are readable here and are NOT persisted upstream. |
encodingRefused[].ref | string | yes | |
encodingRefused[].diagnostic | string | no | |
host | object | yes | What the HOST is doing right now — the runs in flight and the cap over them. An object, not a name: an earlier cut declared this a string and the strict schema rejected every real answer. |
host.concurrencyCap | integer | no | |
host.running | array of object | yes | |
host.running[].key | string | yes | The document key of the issue being worked — `owner/repo#N`. |
host.running[].phase | string | yes | The runner's phase word, in the operator's vocabulary. |
host.running[].startedAt | string | yes | |
host.running[].title | string | yes | |
host.running[].url | string | yes | |
host.truncated | boolean | yes | The open enumeration was cut, so the host's own counts are omitted rather than stated as totals. |
outcome | "unchanged" | yes | |
document | object | yes | The whole document — issues and edges together. Never a patch or a slice; see the family header. |
document.issues | array of object | yes | |
document.issues[].ref | string | yes | An issue, as the store names it — OPAQUE by design: the store compares references and never parses, reformats or resolves one, and this deployment spells a home-repository reference differently from a cross-repository one. So the only shape promised here is non-empty. A well-formed reference the document does not hold is not a malformed request: it answers `rejected` naming the reference, because only the document can know. |
document.issues[].title | string | yes | |
document.issues[].state | "open" | "closed" | yes | |
document.issues[].priority | 0 | 1 | 2 | 3 | no | The RESOLVED tier. See `priorityDisagreement` when the issue's two carriers disagree. |
document.issues[].url | string | no | |
document.issues[].updatedAt | string | yes | THE CONFLICT TOKEN. Send it back in `dispatch`'s `expected` as `ref=updatedAt`; an issue that moved since you read it answers `conflict` rather than overwriting an edit you never saw. |
document.issues[].order | object | null | yes | `null` on a closed issue: it holds no position. |
document.issues[].order.rank | integer | null | yes | `null` when the issue holds no rank. |
document.issues[].order.ready | boolean | yes | |
document.issues[].order.holds | array of object | yes | |
document.issues[].order.holds[].family | "graph" | "tracker" | yes | |
document.issues[].order.holds[].reason | string | yes | |
document.issues[].order.holds[].label | "claimed" | "assigned" | "filtered" | no | The runner's one-word chip for a hold it imposes. `tracker` holds only, and not all of them — a cut-short readiness verdict and a unit over its member cap carry none. |
document.issues[].priorityDisagreement | object | null | yes | Non-null only when the issue's label and its frontmatter name different tiers. Recorded rather than derived — by the time `priority` exists the two have been collapsed into one number. |
document.issues[].priorityDisagreement.usedLabel | string | yes | The tier label AS WRITTEN ON THE ISSUE, never the configured spelling. |
document.issues[].priorityDisagreement.ignoredPriority | integer | yes | The frontmatter tier that was set aside; the format resolves label-first. |
document.issues[].unit | array of string | yes | |
document.issues[].lead | string | null | yes | |
document.issues[].effectivePriority | 0 | 1 | 2 | 3 | null | yes | |
document.issues[].promotedBy | array of string | yes | |
document.edges | array of object | yes | |
document.edges[].id | string | yes | Derived from the edge's content rather than assigned, so the same relationship has the same id wherever it is read. |
document.edges[].kind | "blocked-by" | "decomposed-from" | "duplicate-of" | "serialize-with" | "together-with" | yes | |
document.edges[].from | string | yes | An issue, as the store names it — OPAQUE by design: the store compares references and never parses, reformats or resolves one, and this deployment spells a home-repository reference differently from a cross-repository one. So the only shape promised here is non-empty. A well-formed reference the document does not hold is not a malformed request: it answers `rejected` naming the reference, because only the document can know. |
document.edges[].to | string | yes | An issue, as the store names it — OPAQUE by design: the store compares references and never parses, reformats or resolves one, and this deployment spells a home-repository reference differently from a cross-repository one. So the only shape promised here is non-empty. A well-formed reference the document does not hold is not a malformed request: it answers `rejected` naming the reference, because only the document can know. |
asOf | string | yes | When this copy was last known to have no unprocessed backlog. |
truncated | boolean | yes | The open enumeration was CUT at the row cap — surfaced rather than laundered into a shorter backlog. |
cycleGraphIncomplete | string | null | yes | Non-null when the cycle check could not see the whole graph, naming why — a caller must not read 'no cycles' from an incomplete one. A write that could close a cycle is refused while this is set. |
encodingRefused | array of object | yes | Issues whose relationships could not be encoded back into their bodies. Their edges are readable here and are NOT persisted upstream. |
encodingRefused[].ref | string | yes | |
encodingRefused[].diagnostic | string | no | |
host | object | yes | What the HOST is doing right now — the runs in flight and the cap over them. An object, not a name: an earlier cut declared this a string and the strict schema rejected every real answer. |
host.concurrencyCap | integer | no | |
host.running | array of object | yes | |
host.running[].key | string | yes | The document key of the issue being worked — `owner/repo#N`. |
host.running[].phase | string | yes | The runner's phase word, in the operator's vocabulary. |
host.running[].startedAt | string | yes | |
host.running[].title | string | yes | |
host.running[].url | string | yes | |
host.truncated | boolean | yes | The open enumeration was cut, so the host's own counts are omitted rather than stated as totals. |
outcome | "rejected" | yes | |
reason | string | yes | |
landed | true | no | PRESENT ONLY WHEN THE WRITE ACTUALLY LANDED and the local copy does not yet hold it. DROP the document and the `updatedAt` tokens you are holding and re-read: the next edit judged on them would splice a stale body over the one just written. Absent on an ordinary refusal, where nothing changed. |
outcome | "conflict" | yes | |
upstream | object | yes | The whole document — issues and edges together. Never a patch or a slice; see the family header. |
upstream.issues | array of object | yes | |
upstream.issues[].ref | string | yes | An issue, as the store names it — OPAQUE by design: the store compares references and never parses, reformats or resolves one, and this deployment spells a home-repository reference differently from a cross-repository one. So the only shape promised here is non-empty. A well-formed reference the document does not hold is not a malformed request: it answers `rejected` naming the reference, because only the document can know. |
upstream.issues[].title | string | yes | |
upstream.issues[].state | "open" | "closed" | yes | |
upstream.issues[].priority | 0 | 1 | 2 | 3 | no | The RESOLVED tier. See `priorityDisagreement` when the issue's two carriers disagree. |
upstream.issues[].url | string | no | |
upstream.issues[].updatedAt | string | yes | THE CONFLICT TOKEN. Send it back in `dispatch`'s `expected` as `ref=updatedAt`; an issue that moved since you read it answers `conflict` rather than overwriting an edit you never saw. |
upstream.issues[].order | object | null | yes | `null` on a closed issue: it holds no position. |
upstream.issues[].order.rank | integer | null | yes | `null` when the issue holds no rank. |
upstream.issues[].order.ready | boolean | yes | |
upstream.issues[].order.holds | array of object | yes | |
upstream.issues[].order.holds[].family | "graph" | "tracker" | yes | |
upstream.issues[].order.holds[].reason | string | yes | |
upstream.issues[].order.holds[].label | "claimed" | "assigned" | "filtered" | no | The runner's one-word chip for a hold it imposes. `tracker` holds only, and not all of them — a cut-short readiness verdict and a unit over its member cap carry none. |
upstream.issues[].priorityDisagreement | object | null | yes | Non-null only when the issue's label and its frontmatter name different tiers. Recorded rather than derived — by the time `priority` exists the two have been collapsed into one number. |
upstream.issues[].priorityDisagreement.usedLabel | string | yes | The tier label AS WRITTEN ON THE ISSUE, never the configured spelling. |
upstream.issues[].priorityDisagreement.ignoredPriority | integer | yes | The frontmatter tier that was set aside; the format resolves label-first. |
upstream.issues[].unit | array of string | yes | |
upstream.issues[].lead | string | null | yes | |
upstream.issues[].effectivePriority | 0 | 1 | 2 | 3 | null | yes | |
upstream.issues[].promotedBy | array of string | yes | |
upstream.edges | array of object | yes | |
upstream.edges[].id | string | yes | Derived from the edge's content rather than assigned, so the same relationship has the same id wherever it is read. |
upstream.edges[].kind | "blocked-by" | "decomposed-from" | "duplicate-of" | "serialize-with" | "together-with" | yes | |
upstream.edges[].from | string | yes | An issue, as the store names it — OPAQUE by design: the store compares references and never parses, reformats or resolves one, and this deployment spells a home-repository reference differently from a cross-repository one. So the only shape promised here is non-empty. A well-formed reference the document does not hold is not a malformed request: it answers `rejected` naming the reference, because only the document can know. |
upstream.edges[].to | string | yes | An issue, as the store names it — OPAQUE by design: the store compares references and never parses, reformats or resolves one, and this deployment spells a home-repository reference differently from a cross-repository one. So the only shape promised here is non-empty. A well-formed reference the document does not hold is not a malformed request: it answers `rejected` naming the reference, because only the document can know. |
asOf | string | yes | When this copy was last known to have no unprocessed backlog. |
truncated | boolean | yes | The open enumeration was CUT at the row cap — surfaced rather than laundered into a shorter backlog. |
cycleGraphIncomplete | string | null | yes | Non-null when the cycle check could not see the whole graph, naming why — a caller must not read 'no cycles' from an incomplete one. A write that could close a cycle is refused while this is set. |
encodingRefused | array of object | yes | Issues whose relationships could not be encoded back into their bodies. Their edges are readable here and are NOT persisted upstream. |
encodingRefused[].ref | string | yes | |
encodingRefused[].diagnostic | string | no | |
host | object | yes | What the HOST is doing right now — the runs in flight and the cap over them. An object, not a name: an earlier cut declared this a string and the strict schema rejected every real answer. |
host.concurrencyCap | integer | no | |
host.running | array of object | yes | |
host.running[].key | string | yes | The document key of the issue being worked — `owner/repo#N`. |
host.running[].phase | string | yes | The runner's phase word, in the operator's vocabulary. |
host.running[].startedAt | string | yes | |
host.running[].title | string | yes | |
host.running[].url | string | yes | |
host.truncated | boolean | yes | The open enumeration was cut, so the host's own counts are omitted rather than stated as totals. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_path — A path parameter failed validation. / invalid_body — The request body failed validation. |
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but its scopes do not include the one this operation requires (the `security` entry names it). Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/problem+json | not_found — No such resource for this caller. |
| 429 | application/json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
| 502 | application/problem+json | upstream_auth — An upstream service rejected this deployment's credential. |
POST /api/v1/repos/{id}/order-preview
Operation customer.repos.order-preview. Preview the whole ordering walk under an unsaved draft.
What this repository's backlog WOULD be worked in, under a draft you have not saved — the point being to see the draft's effect before committing to it. THE BODY IS A PARTIAL OVERLAY and omission is meaningful: leaving a field out keeps the SAVED value, and `orderQueries` additionally distinguishes `null` (clear them) from absent (keep them). Sending `null` when you meant 'no opinion' previews a backlog with no queries. `partial` non-null means the walk did not finish and `rows` is a prefix rather than the answer. `configured: false` is a success meaning this deployment cannot reach the provider — the settings stay writable. It WRITES NOTHING: a preview computes and discards.
Security: apiKey
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes | The `tenant_repos` row, as `customer.repos.list` returns it. |
Request body
| field | type | required | description |
|---|---|---|---|
eligibilityMode | "labeled" | "all-issues" | no | OMIT to keep the saved mode. `labeled` requires the eligibility label; `all-issues` considers every open issue. The same closed set `customer.repos.config` takes. |
eligibilityLabel | string | no | OMIT to keep the saved label. Validated against the stored label exactly as the save path validates it. |
orderStrategy | "oldest-first" | "newest-first" | no | OMIT to keep the saved strategy. The direction the picker walks a tier — the same closed set `customer.repos.config` takes. |
excludeAssigned | boolean | no | OMIT to keep the saved value. `false` MEANS false — no presence marker is needed here, because unlike an HTML checkbox a JSON key that is present is present. |
labelMap | array of string | null | no | The label map as `KEY=label` entries, split on the FIRST `=` so a label containing one survives. Keys: `P0`, `P1`, `P2`, `P3`, `type:bug`, `type:feature`, `type:chore`. OMIT to keep the saved map. Send `null` — or an empty array — to preview with NO mapping at all: `null` is the spelling that survives the generated CLI, where a repeatable flag given zero times is indistinguishable from omitting the field and so cannot express an empty list. An entry with an empty value (`P1=`) leaves THAT key unmapped, as the settings form reads a blank control. An unknown or repeated key is `invalid_body` naming the entry. |
orderQueries | array of string | null | no | THREE STATES, and the one field where absent and `null` differ. OMIT to keep the saved queries; send `null` to CLEAR them and preview with none; send an array to replace them. A caller that sends `null` meaning "nothing to say about queries" will preview a backlog with no queries at all. |
Response
| field | type | required | description |
|---|---|---|---|
configured | false | yes | |
configured | true | yes | |
rows | array of object | yes | The walk, in order. |
rows[].position | integer | yes | Where this issue lands in the walk, 1-based. |
rows[].number | integer | yes | |
rows[].title | string | yes | |
rows[].url | string | yes | |
rows[].labels | array of string | yes | |
rows[].createdAt | string | yes | |
rows[].matchedRule | object | null | yes | `null` when the issue is included by the walk's own fallback rather than by a rule. |
rows[].matchedRule.kind | "query" | yes | |
rows[].matchedRule.index | integer | yes | Which `orderQueries` entry matched, 0-based — the row to edit if this attribution is wrong. |
rows[].matchedRule.query | string | yes | The query text as the walk ran it. |
rows[].matchedRule.kind | "tier" | yes | |
rows[].matchedRule.index | integer | yes | |
rows[].matchedRule.label | string | yes | The RESOLVED spelling the walk queried: the mapped label on a repository with a label map, the raw `P0`..`P3` otherwise. |
rows[].matchedRule.kind | "unlabeled-tail" | yes | |
complete | boolean | yes | TRUE only when `rows` IS the whole backlog — which requires PROVING exhaustion, not merely finding no fault. False when `rows` reached `limit` (the walk stops there and drops the rest silently, so exhaustion is unprovable), or when any of `partial`, `searchIncomplete` or `tierTruncated` is set. Read THIS rather than assembling it, and treat `rows` as a prefix whenever it is false. A backlog of exactly `limit` reports false, which is the safe direction. |
partial | object | null | yes | WHY the walk stopped, when it stopped on a query — `queryIndex` attributes it to an `orderQueries` row. One of three shortfall signals; `complete` is the one to branch on. |
partial.kind | string | yes | |
partial.queryIndex | integer | null | yes | |
searchIncomplete | boolean | yes | The provider's own search did not return everything it matched. A shortfall the walk did not choose, so it carries no index. |
tierTruncated | boolean | yes | A priority tier was cut before it was exhausted, so issues below the cut are absent from `rows` even though earlier tiers look complete. |
limit | integer | yes | The ceiling the walk stopped at. |
orderStrategy | string | yes | The strategy actually used, after the overlay was applied — echo it rather than assuming your draft took effect. |
mirroredAsOf | string | no | Present when this ordering was derived from the deployment's local copy rather than a live walk, dating that copy. ABSENT means the provider was walked just now. |
mirroredCopy | "behind" | "unconfirmed" | no | Present only when the copy that answered was NOT current: `behind` means it is known to trail the provider, `unconfirmed` means its position could not be confirmed. Absent alongside `mirroredAsOf` means the copy was current. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_path — A path parameter failed validation. / invalid_body — The request body failed validation. |
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but its scopes do not include the one this operation requires (the `security` entry names it). Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/problem+json | not_found — No such resource for this caller. |
| 409 | application/problem+json | conflict — The resource is not in a state that admits this. |
| 429 | application/json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
| 502 | application/problem+json | upstream_auth — An upstream service rejected this deployment's credential. |
| 503 | application/problem+json | upstream_timeout — An upstream service did not answer in time. / upstream_unavailable — A service this operation depends on did not answer. / upstream_rate_limited — An upstream service is rate limiting us; retry after the stated interval. |
POST /api/v1/repos/{id}/ordering-preview
Operation customer.repos.ordering-preview. Preview what one ordering query matches.
The per-query half of the pair: what a single search actually matches, as a count plus a sample. An author checking a query they are writing wants this; an author checking the RESULT wants `customer.repos.order-preview`. `executedQuery` is the query as actually sent after the product's own scoping was folded in — read it when a result surprises you, because the difference is usually there. `items` is a sample and `totalCount` is the count; do not read the length of one as the other. It WRITES NOTHING.
Security: apiKey
Parameters
| name | in | type | required | description |
|---|---|---|---|---|
id | path | string | yes | The `tenant_repos` row, as `customer.repos.list` returns it. |
Request body
| field | type | required | description |
|---|---|---|---|
query | string | yes | The one search to preview, in the provider's own query syntax. |
Response
| field | type | required | description |
|---|---|---|---|
configured | false | yes | |
configured | true | yes | |
totalCount | integer | yes | How many issues the query matches — the number the author is usually checking. |
items | array of object | yes | A SAMPLE, not the whole match set. `totalCount` is the count; this is what it looks like. |
items[].number | integer | yes | |
items[].title | string | yes | |
items[].labels | array of string | yes | |
items[].createdAt | string | yes | |
executedQuery | string | yes | The query as actually sent, after the product's own scoping was folded in. Compare it against what you wrote when a result surprises you. |
Errors
| status | body | meaning |
|---|---|---|
| 400 | application/problem+json | invalid_path — A path parameter failed validation. / invalid_body — The request body failed validation. |
| 401 | application/json | unauthenticated — No valid API key was presented. Answered by the audience's gate before the operation runs. |
| 403 | application/json | forbidden — The key is valid but its scopes do not include the one this operation requires (the `security` entry names it). Refused before the operation runs. Answered by the audience's gate before the operation runs. |
| 404 | application/problem+json | not_found — No such resource for this caller. |
| 429 | application/json | rate_limited — The per-credential rate limit refused the request before it was authenticated. Carries `Retry-After`. Answered by the audience's gate before the operation runs. |
| 500 | application/problem+json | internal_error — The request failed for a reason the caller cannot fix. |
| 502 | application/problem+json | upstream_auth — An upstream service rejected this deployment's credential. |
| 503 | application/problem+json | upstream_timeout — An upstream service did not answer in time. / upstream_unavailable — A service this operation depends on did not answer. / upstream_rate_limited — An upstream service is rate limiting us; retry after the stated interval. |
Changelog
What changed in the contract and why, newest first. An additive change keeps the version and is recorded under it; a breaking change is a new major and a new document.
Version 1.0.0
The deployed contract's table of contents: every operation id it serves and the version it was built from, unauthenticated and served from the registry alone. `descant version --check` now asks it and reports how many operations — and which — a binary is behind the deployment, so “run the CLI from main when the release is behind” becomes a sentence a seat can act on rather than one it has to already know the answer to.
The eligibility label and eligibility mode became writable, closing the second gap where the config read showed a setting no API could change. The label must already exist on the repository: this write does not create it.
The run list is paged and filterable. It had been capped at fifty rows since it shipped — the handler asked the query layer for no limit and got that primitive's own default — so a tenant past its fiftieth run was reading a truncated list the response shape called complete. Follow `nextPageToken` until it is `null`, and narrow with `repositoryId`, a repeatable `state`, or a `createdAfter`/`createdBefore` window. THE ROWS MOVED FROM `runs` TO `items`, deliberately: the shared page envelope is what every other list here uses, and a renamed field is a break you can see at the first response rather than a silent truncation you cannot. The scope is unchanged — still ownership-scoped, so a removed repository's runs are still listed.
One run, as its own page shows it: the five canonical phases with their statuses and mapped copy, the server clocks a stall is measured against, the review findings and the follow-up counts. It renders the SAME projection the dashboard does, so the two cannot tell a customer different stories about one run — the raw failure code and the raw review checkpoint stay behind it, as they do on the page.
customer.repos.issues.list,customer.repos.labels.listThe two repository pickers, which until now existed only as lists a person clicked: the issues a run can be started for, and the label names the eligibility setting matches on. Both answer `configured: false` as a SUCCESS where this deployment cannot enumerate — enqueueing by issue number and setting a label by name keep working, so that case is a shape to branch on rather than an error to retry.
customer.auth.device.start,customer.auth.device.token`descant login` now opens a browser by default. These two unauthenticated operations are that handshake: one mints a short code a person approves in the dashboard, the other polls until they have, and answers the resulting key once. Pasting a key still works — `descant login --key`, or any non-terminal stdin — and what the browser flow stores is an ordinary tenant key in the ordinary place, so precedence, `whoami` and `descant mcp` are unchanged.
customer.invites.list,customer.invites.create,customer.invites.revokeBeta invites, described by the contract the routes were already serving: list an account's invites with the budget left, mint one and read its code the single time it is ever shown, and revoke one to reclaim its slot. They act on the account that CREATED the key rather than the tenant's current owner, so which key you use decides whose budget you spend — and a key with no creating account resolves to nothing, the same answer as asking about somebody else's invite.
customer.repos.grooming.candidates,customer.repos.grooming.document,customer.repos.grooming.dispatchThe grooming workspace, for callers that have no workspace: the relationships Descant would propose with the evidence that suggested each one, the document the issues currently encode, and the single fenced mutation that lands an edit into the issue bodies. The reads are served from the local copy of the backlog, because a proposal is a proposed write against the copy you are looking at — and when that copy cannot serve they refuse with a reason rather than returning an empty list, which is what a working detector returns over a backlog with nothing to propose.
customer.repos.order-preview,customer.repos.ordering-previewBoth ordering previews, for callers with no settings form: the whole walk under an unsaved draft, and what one query matches. The draft is a partial overlay and omitting a field keeps the saved value — except `orderQueries`, where `null` clears and absent keeps, which are different requests. Neither writes anything.
The key list is now paged, taking the same opaque page token every other list here does. It shipped unpaginated on the reasoning that a tenant's key count is bounded by people minting them, and the mint beside it falsified that: a key holding `keys:write` mints without a person, and a revoke is soft, so the list only ever grows. Follow `nextPageToken` until it is `null`.
What a repository is configured to PICK — the label that makes an issue eligible, the order the picker walks, and whether assigned issues are excluded. Effective values, with the defaults already composed and no flag saying whether a row existed, because an absent row and a default-valued one pick identically.
The first customer WRITE, and the one that stops spend. It answers WHICH ending the cancel reached rather than a boolean — `cancel_requested` means recorded but not yet stopped and calls for a re-read, `already_finished` is final. An `outcome_unknown` answer means the cancel may have landed: verify before calling again. The `billing_paused` code and its own CLI exit were minted alongside it, ahead of the first operation that declares them — review-and-fix carries the runner's typed billing reason, and cancel cannot produce one.
Changing what a repository picks, not just whether it is paused: the pickup order and the per-repo concurrency sub-cap became writable, closing the gap where the config read could show an order no API could change. A partial patch — an absent field is left alone, and a null sub-cap clears it.
customer.repos.poller-diagnosticsWhy nothing is being picked when the poller IS running — how many issues carry the eligibility label, and which buckets hold the rest. A live provider call, so the answer is a union: branch on `configured`, then on `mode`, before reading any count, because a deployment without author-App credentials and an `all-issues` repository cannot produce counts, and fabricated zeros would read as "nothing is eligible" rather than "this was not measured".
The first contracted customer operation: a tenant's runs, newest first, with the billing signal beside them, under the API key's own tenant.
The tenant's repositories — the entry point for every repo-scoped operation, since each takes the repo id this list is the only programmatic source of.
One repository's recorded settings intents, newest first: who asked for which setting and when, as the product recorded it.
Why a repository is or is not being polled — the read that answers 'why is nothing happening', with the cause named rather than a boolean, because each cause has its own remedy.
The Model Context Protocol endpoint, so an agent can drive these operations as typed tools. Its scope is the envelope and not the contents: every tool call inside is still checked against the scope its own operation requires.
Rotation without a gap: a successor carrying the target's label and scopes, and a grace window in which the old key still authenticates, so a caller can swap the secret before the old one stops.
customer.keys.list,customer.keys.create,customer.keys.revokeThe credential surface itself: a tenant's keys, a mint that can hold no scope the minting key does not already hold, and a revoke. Attenuation is what makes a narrow key reachable without the console.
The trail every authenticated call leaves, made readable: one row per call any of this tenant's credentials made, refusals included, so a refusal can be attributed to a credential rather than guessed at.