Docs
Search docs

Long-poll: claim a Ready job whose runs_on matches the runner's labels

POST/api/runners/{runner_id}/claim

claimJob

Requires authentication using a bearer token — see tokens and scopes.

curl

curl -X POST 'https://neosource.dev/api/runners/RUNNER_ID/claim' \
  -H 'Authorization: Bearer $NEOSOURCE_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"labels":[]}'

fetch

fetch("https://neosource.dev/api/runners/RUNNER_ID/claim", {
  method: "POST",
  headers: {
    Authorization: "Bearer $NEOSOURCE_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({"labels":[]}),
});

Path parameters

runner_idrequired

string

Request bodyrequired

application/json

ClaimJobRequest

object

claim_token

string | null

A fresh UUID per long-poll. A poll whose token is no longer the runner's latest claims nothing. 400 when not a UUID.

labelsrequired

array

Labels the runner is willing to handle. The claim only matches a job whose `runs_on` is satisfied by some subset of these.

items

string

provides_services

boolean | null

The agent materialises a job's `services:` sidecars. Only the pod backends do today — `docker_shell` rejects such a job before its first step — so a job declaring `services:` is withheld from an agent that answers `false`. **Absent/`null` is "did not say", NOT `false`.** An agent deployed before this field existed omits it, and the scheduler then assumes an answer from the runner's registered `vm_backend` (`neosource_core::model::assumed_provides_services`). Reading silence as `false` would exclude the hosted pool — the only claimant such a job has — for as long as the server is newer than the agent, and the job would sit `ready` with nothing logged or rendered.

supersedes

string | null

The token of a previous poll the agent abandoned without reading its response: a job that poll claimed is requeued before this one claims. 400 when not a UUID.

supports_merge_group

boolean

The agent applies the server's ref context, including merge-group `base_ref`, to every step on both execution backends. Legacy agents omit this and must never claim candidate verification jobs.

supports_shell_template

boolean

The agent runs a step's `shell_template` (`shell: nu {0}`, `bash -c "…{0}"`) as written. A property of the agent BUILD the server cannot derive, so **silence is a denial**: an agent older than this field drops the template and runs the body under the legacy `shell` hint, and every job with a templated step is withheld from it.

vm_capacity
one of
  • VmCapacityDto

    object

    A direct-VM node's capacity, in host memory MiB and whole vCPUs (ADR 0146). The budget is what the node offers jobs; free is what is left of it.

    free_memory_mibrequired

    integerint32

    free_vcpusrequired

    integerint32

    memory_budget_mibrequired

    integerint32

    vcpu_budgetrequired

    integerint32

  • null

wait_timeout_secs

integer | nullint32

How long the server should wait (long-poll) before returning `None`. Server caps this internally; values larger than the cap are clamped.

Responses

200Claim or timeout sentinel

application/json

JobClaimResponse

object

cache_commit_allowed

boolean

Whether this job may commit its writable cache disk back as the new cache generation (ADR 0138 §3): `run.trusted` AND the event is `push`, `schedule` or `workflow_dispatch` AND the ref is a protected branch. Stricter than the cache-write gate's `run.trusted` alone, which a writer's same-repo PR also passes. The server computes it at claim time; the runner never derives trust. Absent means `false`, so an older server is fail-closed; an older runner ignores it. `ns-runner`'s direct-VM executor consumes it: the job's cache disk is committed only when this is true, the job succeeded and its VM exited cleanly.

capacity_aware

boolean

The server understands `vm_capacity` (ADR 0146). Absent (an older server) means it ignored it and claimed FIFO.

changes
one of
  • ChangesContext

    object

    The `neosource.changes` subtree one job sees (ADR 0139 §4). The claim wire (`JobClaimResponse.changes`) and the runner's `GithubContext` carry it; the server's job-`if:` context is built from the same value.

    base

    string

    The range's base commit, 40-hex, or `""` when the run has no range.

    complete

    boolean

    Whether `files` is the exact set. When `false`, `pathsChanged()` is `true` and anything may have changed.

    files

    array | null

    Every changed path (a rename contributes its old and new path). Present only when `complete`.

    items

    string

    head

    string

    The range's head commit, 40-hex, or `""` when the run has no range.

  • null

env_record
one of
  • EnvRecordClaimDto

    object

    `JobClaimResponse.env_record`: what a claim is told of the repo's environment record (ADR 0188 §2). The string fields are log and consumer data, not validated newtypes on the wire: the server validated them when the record was written, and a consumer (the agent's `plan()`, slice 3 PR C) must parse every `signed_paths` entry as a `NixStorePath` under its store root and ignore the record on any failure.

    identityrequired

    string

    The writer's identity: its shell's drvPath, or `outs:<hex>` for bare packages.

    keyrequired

    string

    The server key, hex. For logs only: never sent back, and a report carrying one has it ignored.

    signed_bytesrequired

    integerint64

    Their total NAR size.

    signed_pathsrequired

    array

    The record's upstream-signed (tier a) store paths, sorted, at most `MAX_ENV_RECORD_PATHS` (4096).

    items

    string

  • null

event

object

The GitHub-Actions webhook payload for the triggering event: `${{ github.event }}`, and the bytes the runner writes to the file `$GITHUB_EVENT_PATH` points at. Server-built, because only the scheduler knows the bookmark's pre-advance tip — it cannot be reconstructed runner-side. This is what makes `dorny/paths-filter` and `tj-actions/changed-files` work: both read `payload.before`, and `paths-filter` only does so when `eventName === 'push'`. Deliberately an open vocabulary (a webhook body, not a closed set), hence `Value` — same posture as `EnqueueRunRequest.cause`. Absent on older servers; the runner guards a non-object back to `{}`.

expires_atrequired

integerint64

github_refs
one of
  • GithubRefContext

    object

    Ref fields resolved once from the run cause, shared by scheduling and both runner execution backends. Merge groups have a target but no published ref.

    base_refrequired

    string

    head_refrequired

    string

    refrequired

    string

    ref_namerequired

    string

    ref_typerequired

    string

  • null

github_tokenrequired

string

Per-job ephemeral `GITHUB_TOKEN`. The runner sets `secrets.GITHUB_TOKEN` (and its alias `secrets.NEOSOURCE_TOKEN`, ADR 0125 §4) to this value and exposes both as env vars to every step.

jobrequired

WorkflowRunJobResponse

object

cache_transfer
carried_from
compile_cache
definitionrequired

object

Definition slice the runner needs to execute. JSON shape mirrors `neosource_core::model::WorkflowJobDef`.

enqueued_atrequired

integerint64

executed_by_runner

string | null

The runner that claimed this job (UUID hex), materialized at claim like `executed_on`. `null` until first claim, on carried jobs, and ALWAYS when `executed_on` is `actor` (a stable id would link one person's machine across public runs).

executed_on
executed_runner_name

string | null

Display name of the runner that executed this job, as it was at claim (a later rename does not rewrite history). Set ONLY when `executed_on` is `workspace`: a personal runner's name defaults to its hostname and run pages can be public, so an actor-bound execution is shown as "ran on @handle's machine" instead, and a hosted one as "hosted". Also `null` for a runner that never reported a name.

exit_code

integer | nullint32

finished_at

integer | nullint64

idrequired

string

job_keyrequired

string

log_offsetrequired

integerint64

log_truncated

boolean

The job's log hit its per-job size cap (ADR 0029 §"Log size per job / retention"). The log ends with a server-authored truncation marker line and nothing more will ever be appended. This flag, not that line, is the authoritative signal — a job can echo the same text.

matrix_values

object | null

Matrix combination, when this job row came from matrix expansion.

needsrequired

array

Other jobs that must succeed before this one is eligible.

items

string

pod_scheduled_at

integer | nullint64

When this job's pod was scheduled onto a node (epoch ms; Kubernetes reports it at 1 s precision). `null` until the runner reports it, for a job that did not run on a k8s pod, and for every job that ran before the field existed. `started_at` → this is time spent waiting for capacity.

pod_started_at

integer | nullint64

When this job's runner container started (epoch ms; 1 s precision). `pod_scheduled_at` → this is the pod's boot (image pull, Nix store, sandbox). Same `null` cases as `pod_scheduled_at`.

repo_idrequired

string

run_idrequired

string

runs_on

array

Labels the job's `runs-on:` requires (a stored legacy `runs-on: nix` job reports `["ubuntu-latest"]`, ADR 0174).

items

string

started_at

integer | nullint64

statusrequired
WorkflowRunJobStatus
steps

array

Per-step records reported by the runner. Empty until the runner posts its first snapshot (and always empty on the claim payload).

lease_idrequired

string

matrix_leg
one of
  • LegPosition

    object

    A static matrix leg's position among its job's legs (T7-P1), as the claim hands it to the runner (#1580). The runner sees a leg's definition narrowed to one combo, so this is the only way it can answer `strategy.job-index` / `strategy.job-total`. Consumers treat `index >= total` or `total == 0` as absent.

    indexrequired

    integerint32

    0-based, [`WorkflowRunJobRecord::leg_index`].

    totalrequired

    integerint32

    How many legs the job was split into.

  • null

needs_views

object

Terminal `needs:` predecessors of the claimed job, keyed by job key: their reported `outputs:` plus GHA result string. The runner binds these as the `${{ needs.<job>.* }}` expression context. Empty for jobs without `needs:` (and on older servers — wire-compat default).

additional properties

JobNeedsView

object

A completed predecessor job as seen by a dependent job's `needs.<job>.*` expression context: its `outputs:` block (evaluated by the runner at job end and reported on `complete`) plus its GHA-shaped result string. Assembled server-side at claim time from the terminal sibling rows the claimed job `needs`, shipped in the claim payload, and bound into the expression context by the runner (`neosource_runner_core::expr::set_needs`).

outputs

object

`outputs:` values the predecessor surfaced (`needs.<job>.outputs.*`).

additional properties

string

result

string

`"success"` / `"failure"` / `"cancelled"` / `"skipped"` — GHA's `needs.<job>.result` vocabulary.

nixpkgs_ref

string | null

Workflow-level nixpkgs flake reference used to resolve bare entries in the job's `packages:` list. `None` means the workflow pinned none: the runner uses `platform_nixpkgs_ref`, else its compiled default. Defaulted for wire compatibility with older servers.

platform_nixpkgs_ref

string | null

The nixpkgs revision this job's run was validated against (ADR 0175), recorded when the run was created (a re-run inherits its source's when the source ran a job). Sent on every claim; a run created before the server recorded it gets the server's current pin. The runner evaluates bare entries at it when the workflow pinned no `nixpkgs:` (`nixpkgs_ref` above always wins), so a job queued across a server nixpkgs bump, or claimed by a runner built at another pin, still runs what the server checked. Absent (an older server) means the runner's compiled default, as before; an older runner ignores it.

registry_hosts

array

Registry hostnames this server answers `/v2/` on beyond the forge host itself (ADR 0125 §3) — `neocr.io` in production, empty when `NEOSOURCE_REGISTRY_HOST` is unset. The runner writes a containers-auth entry carrying `github_token` for each, plus the forge host it already talks to, so `docker push` to the job's own registry needs no login step. Named by the server because a hostname is a deployment fact the runner cannot derive. Defaulted for wire compat: an older server sends nothing and only the forge-host entry is written.

items

string

repository

string

`owner/slug` of the run's repo (GHA's `github.repository`). `actions/checkout` clones `{scheduler}/{repository}.git` with the job token; the git gateway accepts that token read-only on exactly this repo. Defaulted for wire-compat with older servers.

runrequired

WorkflowRunResponse

object

actor_handle

string | null

Handle of the account whose action triggered the run (the pusher, the PR author, the manual dispatcher). `null` for actor-less causes (schedule) and on write receipts that skip the lookup (enqueue).

attemptrequired

integerint32

Re-run attempt (GHA's `github.run_attempt`), starting at 1. A re-run creates a new run that keeps `run_number` and increments this.

branch

string | null

The bookmark (branch) a `push` or ref-aware manual run targeted; `null` for other events and legacy manual rows.

commit_summary

string | null

First line of the trigger commit's message, when that commit is in the metadata projection. Populated on list/detail reads; `null` on write receipts and causes without a pinned commit.

enqueued_atrequired

integerint64

eventrequired
WorkflowRunEvent
finished_at

integer | nullint64

idrequired

string

inputs

object | null

The `workflow_dispatch` inputs a `manual` run was started with — the typed, validated, default-filled map (bool / number / string values, alphabetical by name), possibly empty. `null` for every other event. The runner exposes it as `${{ inputs.<name> }}`; the run page lists it.

landed_pull_request
previous_attempt_id

string | null

The run this one is a re-run of (UUID hex), or `null` for a first attempt. Lets the UI link to the previous attempt.

pull_request_number

integer | nullint32

The pull request number for `pull_request` runs.

repo_idrequired

string

rerun_of_failed

boolean

This attempt was created by "re-run failed jobs": jobs whose `carried_from` is set were carried from an earlier attempt rather than executed, and artifact list/download inherit from earlier attempts. `false` for first attempts and full re-runs.

run_numberrequired

integerint64

Per-workflow monotonic ordinal (GHA's `github.run_number`). Run 1 of a workflow is 1, the next is 2, etc. Surfaced to steps and used by the runner to populate `${{ github.run_number }}`. Re-run attempts of one logical run share this number.

started_at

integer | nullint64

statusrequired
WorkflowRunStatus
test_summary
toolchain_manifest
trigger_commit

string | null

Exact commit (40-char git hex) the run executes: push tip, PR head (the merge commit for the `closed` run a merge dispatches), or the CAS-consistent branch target of a ref-aware manual dispatch. `null` only for legacy manual rows and causes without a commit target (such as schedules). `neo push --runner` polls on this to find a push's runs.

workflow_idrequired

string

secrets

object

Workspace CI secrets injected into this job's execution, surfaced as `${{ secrets.NAME }}` (Phase 4b). Populated **only** for a trusted run; an untrusted / fork run receives an empty map (ADR 0027 — no secrets ever reach fork CI). The runner folds these into its `secrets` map alongside `GITHUB_TOKEN`.

additional properties

string

source_bookmark

string | null

Bookmark this run builds, which the runner reports as GHA's `github.ref_name`. Set for a push, a ref-aware manual dispatch, and — resolved to the repo's default branch — a schedule or a manual row persisted before dispatch carried a bookmark. Absent for pull-request and jj-change causes, which have no honest bookmark: a PR's ref is `refs/pull/<n>/merge` and resolves through the PR instead. `actions/checkout` fetches this ref; absent, it fetches the remote HEAD. Must agree with the `github.ref` the server decided the job's `if:` from — see `WorkflowRunCause::checkout_bookmark`.

toolchain_manifest
one of
  • ToolchainManifest

    object

    Everything one run resolved, keyed by [`ToolchainKey::storage_key`]. Written once per run and read by every later reader — the second matrix leg, a re-run, a replay. Authoritative over the constraint that produced it: a re-run re-materializes these artifact identities rather than asking the catalog again (ADR 0083 §2).

    catalog_version

    string | null

    sha256 of the catalog file that produced these entries. **Provenance only, never a lookup key.** The catalog is compiled into the binary, so a server running catalog C′ cannot evaluate against pinned catalog C — the rows are not in the process. Pinning a hash you cannot look up is a promise the deployment model cannot keep, which is why authority sits on the OUTPUT (ADR 0083 §2).

    entries

    object

    `storage_key` → what it resolved to.

    additional propertiesResolvedToolchain
    unreadable

    object

    `storage_key` → why a tolerant reader could not interpret that entry. **Populated only by a reader, never by the server.** The server writes what it can serialize, by construction, so this is empty in every row of `workflow_runs.toolchain_manifest`. A reader fills it in when it meets a [`ToolArtifact`] kind it predates and sets that one entry aside rather than failing the whole payload. A missing key and an unreadable key are NOT the same state: missing takes the late-bound resolve route and, failing that, an approximating local table; unreadable must REFUSE at the point of use. A job that never asks for an unreadable key runs untouched — that is what makes setting one entry aside safe at all. Additive on the wire (`default`, and skipped when empty), so an older reader ignores it and the common empty case costs nothing.

    additional properties

    string

  • null

trigger_commit

string | null

40-char git hex of the exact commit that triggered the run (the pushed tip, the PR head, or the merge commit for the `closed` run a merge dispatches) — GHA's `github.sha`. `actions/checkout` checks out exactly this commit instead of the bookmark tip at execution time, so a job always builds the commit that triggered it even when later pushes have moved the bookmark. Absent for schedule/legacy manual causes and on older servers (wire-compat default). Ref-aware manual dispatches carry their pinned branch tip.

vm_root
one of
  • VmRootDto

    object

    A direct-VM job root by content ([`JobClaimResponse::vm_root`]).

    sha256required

    string

    sha256 of the root's `root.ext4.zst`, 64 lowercase hex characters.

    sizerequired

    integerint64

    The `root.ext4.zst` payload's size in bytes.

  • null

Standard errors

Bodies documented once for the whole API — see standard errors.

  • 400Bad Request — one of: invalid_input
  • 401Authentication required
  • 403Forbidden — one of: forbidden
  • 404Not Found — one of: not_found
  • 409Conflict — one of: conflict
  • 429Rate limited — retry after the `Retry-After` header
  • 500Internal server error
  • 503Service temporarily unavailable / at capacity — retry after the `Retry-After` header
  • 504Gateway timeout — the request exceeded the server's handling budget

Schemas

Referenced above. Listed here rather than expanded inline, so the same definition is not repeated at every level.

JobCacheTransferResponse

object

A job's cache transfer, measured by the server as it served the job's cache requests (`workflow_run_job_cache_transfer`). Every byte figure is COMPRESSED object bytes as the server sent or stored them (the actions/cache tarball, the build-cache pool object), never the unpacked size. A download counts when the server builds a hit response, at the entry's full size, even if the client aborts mid-body; a `lookup` or HEAD never counts. An upload counts only once durably stored. Every request that reaches the server counts once (a repeated pool PUT counts each time). Telemetry, not accounting: a crashed API node loses up to 30 s of counts, and nothing is ever counted twice.

native_restored_bytes

integer | nullint64

Bytes those restores sent. `null` exactly when `native_restores` is.

native_restores

integer | nullint64

actions/cache restore hits served to this job. `null` = not measured: the job's row predates restore tracking, so it may have restored. A job that ran during the deploy introducing this count may under-count (a restore served by a not-yet-updated node is missed).

native_save_skippedrequired

integerint64

actions/cache saves refused because the key already existed (keys are immutable); they stored nothing.

native_saved_bytesrequired

integerint64

native_savesrequired

integerint64

actions/cache saves that stored a new entry.

pool_bytes_downrequired

integerint64

Bytes the pool's hits sent.

pool_bytes_uprequired

integerint64

pool_getsrequired

integerint64

Build-cache pool GETs (a miss counts with 0 bytes).

pool_hitsrequired

integerint64

pool_putsrequired

integerint64

Build-cache pool PUTs that stored, and their bytes.

CarriedFromResponse

object

Where a carried job ([`WorkflowRunJobResponse::carried_from`]) executed.

attemptrequired

integerint32

That run's `attempt`.

job_idrequired

string

The job that executed (UUID hex).

run_idrequired

string

Its run — an earlier attempt of this run (UUID hex).

CompileCacheResponse

object

A job's compile-cache result, as SERVED to a reader: the raw counters plus the two figures derived from them. The derived fields are computed at read time rather than stored, so improving the model never means a migration and the inputs stay visible beside the output. Both are `null` where they have no meaning — notably `estimated_saved_ms` on a job that hit on everything and therefore never measured a compile.

compilationsrequired

integerint64

compile_msrequired

integerint64

estimated_saved_ms

integer | nullint64

Modelled ms the cache saved: `hits × (mean compile − mean cache read)`. `null` when the terms do not exist — see `JobCompileCache`.

hit_rate

number | nulldouble

Hits ÷ (hits + misses), or `null` when there were no requests.

hitsrequired

integerint64

missesrequired

integerint64

read_hit_msrequired

integerint64

requestsrequired

integerint64

RunnerBindingKind

string

Which trust binding a job executed under (ADR 0056 §1/§7). Serialized on the wire and in `workflow_run_jobs.executed_on` as the `snake_case` strings `hosted` / `workspace` / `actor`.

"hosted""workspace""actor"

WorkflowRunJobStatus

string

Lifecycle of an individual job inside a run. Distinct from the run-level status because `needs:` ordering means some jobs are `Waiting` while others are `Running`. The serde `snake_case` strings are the wire vocabulary the API emits (pinned by the `enum_wire_consistency` tests).

"waiting""ready""running""cancelling""succeeded""failed""cancelled""skipped"

WorkflowRunStepResponse

object

One step of a job, reported by the executing runner. Offsets index into the job's log byte stream (`GET /api/jobs/{job_id}/logs`), so the UI can slice the job log per step. `log_end_offset` is `null` while the step is in flight.

cache
one of
  • StepCacheReportDto

    object

    What a step's cache RESTORE got back. `null` on the step it belongs to means "this step said nothing about caching" — never "miss". Every step that predates the feature, and every step that is not a cache restore, reads null, so a reader that renders absence as a miss libels most of the platform's history.

    bytes

    integer | nullint64

    Compressed size of the restored entry, when the transport knew one.

    matched_key

    string | null

    The key that MATCHED — the primary key on an exact hit, the restore-key-matched entry's key on a partial one. `null` on a miss.

    outcomerequired

    StepCacheOutcome

    string

    What a cache RESTORE got back (ADR 0016; phase 2 of `plans/ci-cache-and-step-visibility-2026-08.md`). Scoped to restores on purpose. A restore is one-to-one with a step, so the fact has an unambiguous owner and an unambiguous meaning — "did the cache help here". A SAVE is not: the combined `actions/cache@v4` saves from a job-end post-hook that runs after every step has finished, so attributing it to a step index would mean inventing one. Adding a save outcome later is additive to this enum and to the column that stores it.

    "exact""partial""miss"

  • null

finished_at

integer | nullint64

log_end_offset

integer | nullint64

log_start_offsetrequired

integerint64

namerequired

string

started_at

integer | nullint64

statusrequired

WorkflowRunStepStatus

string

Lifecycle of one step inside a job. Written by the runner (it knows the boundaries exactly) via an idempotent full-snapshot upsert; never derived server-side from log parsing.

"queued""running""succeeded""failed""skipped"

step_indexrequired

integerint32

WorkflowRunEvent

string

A [`WorkflowRunCause`] with its payload stripped — the closed event vocabulary run list rows surface (`WorkflowRunResponse.event`). The serde `snake_case` strings are the wire vocabulary (pinned by the `enum_wire_consistency` tests).

"push""change""manual""schedule""pull_request""merge_group"

WorkflowRunLandedPullRequest

object

The merged PR a run's trigger commit landed from (`WorkflowRunResponse.landed_pull_request`).

numberrequired

integerint32

titlerequired

string

WorkflowRunStatus

string

Lifecycle of a workflow run. Mirrors the GHA-shaped surface that the SPA renders. The serde `snake_case` strings are the wire vocabulary the API emits (pinned by the `enum_wire_consistency` tests).

"pending_approval""queued""quota_blocked""concurrency_blocked""running""cancelling""succeeded""failed""cancelled"

WorkflowRunTestSummary

object

Per-run test results parsed from a `JUnit` report artifact at run completion. Counts are individual testcases.

failedrequired

integerint32

failuresrequired

array

First N failing tests (server caps the stored list).

items

WorkflowRunTestFailure

object

One failing test out of the run's `JUnit` report.

message

string | null

The failure/error message attribute, when present.

namerequired

string

Test identity: `classname::name` when the report carries a classname, else the bare testcase name.

passedrequired

integerint32

skippedrequired

integerint32

totalrequired

integerint32

ToolchainManifest

object

Everything one run resolved, keyed by [`ToolchainKey::storage_key`]. Written once per run and read by every later reader — the second matrix leg, a re-run, a replay. Authoritative over the constraint that produced it: a re-run re-materializes these artifact identities rather than asking the catalog again (ADR 0083 §2).

catalog_version

string | null

sha256 of the catalog file that produced these entries. **Provenance only, never a lookup key.** The catalog is compiled into the binary, so a server running catalog C′ cannot evaluate against pinned catalog C — the rows are not in the process. Pinning a hash you cannot look up is a promise the deployment model cannot keep, which is why authority sits on the OUTPUT (ADR 0083 §2).

entries

object

`storage_key` → what it resolved to.

additional properties

ResolvedToolchain

object

What the catalog answered, and what a re-run re-materializes.

artifactrequired

ToolArtifact

Where a resolved toolchain's bytes come from.

one of
  • object

    A bare nixpkgs attribute at a pinned revision. One row serves every system; the ref may differ per version row, which is how one platform serves Node 20 and Node 26 at once (ADR 0075 dynamic routing is what makes that affordable).

    attrrequired

    string

    kindrequired

    string

    "nix"

    nixpkgs_refrequired

    string

  • object

    An upstream archive, fetched once platform-side, verified against an upstream-published checksum and stored content-addressed. The digest is the identity; the URL is provenance.

    kindrequired

    string

    "dist"

    layoutrequired
    DistLayout
    sha256required

    string

    urlrequired

    string

  • object

    A flake ref or a bare attribute with no catalog row — ADR 0057's escape hatch. Recorded as a raw installable rather than a tool version, because that is honestly all we know about it.

    installablerequired

    string

    kindrequired

    string

    "passthrough"

  • A bare nixpkgs store path (ADR 0101 §1). Materializing it is SUBSTITUTION, not evaluation: no tree fetch, no eval, no `[refs]` entry, no fleet rollout. 289,521 pairs cost the resolver what 2 cost. A validated payload rather than three loose fields: both of its invariants are enforced by [`StorePathArtifact`], on construction and on deserialize alike. The wire form is unchanged — serde flattens an internally-tagged newtype variant — so this is still `{"kind":"store_path","store_path":…,"outputs":[…],…}`.

    all of
toolrequired

string

Canonical tool name (aliases resolve to it), or the raw name for a passthrough installable.

variant

string | null

Distribution/vendor where the tool has one (`temurin`, `zulu`, …).

versionrequired

string

The concrete version. **Open vocabulary, deliberately `String`** — the set of version strings is unbounded by construction, so this is the documented exception to the closed-set-DTO-enum rule.

unreadable

object

`storage_key` → why a tolerant reader could not interpret that entry. **Populated only by a reader, never by the server.** The server writes what it can serialize, by construction, so this is empty in every row of `workflow_runs.toolchain_manifest`. A reader fills it in when it meets a [`ToolArtifact`] kind it predates and sets that one entry aside rather than failing the whole payload. A missing key and an unreadable key are NOT the same state: missing takes the late-bound resolve route and, failing that, an approximating local table; unreadable must REFUSE at the point of use. A job that never asks for an unreadable key runs untouched — that is what makes setting one entry aside safe at all. Additive on the wire (`default`, and skipped when empty), so an older reader ignores it and the common empty case costs nothing.

additional properties

string

ResolvedToolchain

object

What the catalog answered, and what a re-run re-materializes.

artifactrequired

ToolArtifact

Where a resolved toolchain's bytes come from.

one of
  • object

    A bare nixpkgs attribute at a pinned revision. One row serves every system; the ref may differ per version row, which is how one platform serves Node 20 and Node 26 at once (ADR 0075 dynamic routing is what makes that affordable).

    attrrequired

    string

    kindrequired

    string

    "nix"

    nixpkgs_refrequired

    string

  • object

    An upstream archive, fetched once platform-side, verified against an upstream-published checksum and stored content-addressed. The digest is the identity; the URL is provenance.

    kindrequired

    string

    "dist"

    layoutrequired

    DistLayout

    string

    How a dist archive unpacks into the tool-cache root.

    "strip_one""flat"

    sha256required

    string

    urlrequired

    string

  • object

    A flake ref or a bare attribute with no catalog row — ADR 0057's escape hatch. Recorded as a raw installable rather than a tool version, because that is honestly all we know about it.

    installablerequired

    string

    kindrequired

    string

    "passthrough"

  • A bare nixpkgs store path (ADR 0101 §1). Materializing it is SUBSTITUTION, not evaluation: no tree fetch, no eval, no `[refs]` entry, no fleet rollout. 289,521 pairs cost the resolver what 2 cost. A validated payload rather than three loose fields: both of its invariants are enforced by [`StorePathArtifact`], on construction and on deserialize alike. The wire form is unchanged — serde flattens an internally-tagged newtype variant — so this is still `{"kind":"store_path","store_path":…,"outputs":[…],…}`.

    all of
    • StorePathArtifact

      object

      The `store_path` artifact's payload, with its invariants enforced. Two of them, and neither was a type before: 1. **`store_path` is a well-formed store path UNDER THE REAL STORE.** It deserializes straight out of `workflow_runs.toolchain_manifest` jsonb and [`ToolchainManifest::has_store_path`] is the agent's whole authorization surface, so as a raw `String` this answered "recorded" for anything that byte-matched a recorded entry — and left the grammar as the daemon's job alone rather than a property of the value. It takes BOTH halves to be that claim, and only the first was a type before: [`NixStorePath`] carries the GRAMMAR and is root-agnostic on purpose (the daemon's root is env-configurable and its own tests point it at a directory of their own), so `/etc/<hash>-passwd` is a shaped path and that type accepts it by design. [`StorePathArtifact::new`] pins the ROOT to [`NixStoreRoot::default`], on construction and on deserialize alike, which is what makes a recorded artifact naming anything outside the real store unconstructable. 2. **`bin_output` is one of `outputs`.** The `deno` → `denort` lesson is that which output carries `bin/` is index-ANSWERED, never guessed, and an answer naming an output the derivation did not have is not an answer. That was prose in a doc comment; it is checked here. `revision` stays an unvalidated `String` on purpose, and the asymmetry is the point: it is provenance only. No decision is made on it — that is exactly why [`ToolArtifact::nixpkgs_ref`] does not return it — so a malformed revision misleads a reader without routing anything, while a malformed store path is an authorization answer.

      bin_outputrequired

      string

      Which output `store_path` is. Index-answered, never guessed — the `deno` → `denort` lesson says a name heuristic is not enough. A member of `outputs` by construction.

      outputsrequired

      array

      Every output the derivation had, for provenance only.

      items

      string

      revisionrequired

      string

      The revision the index chose (ADR 0101 §2a records it "beside the store path"). Deliberately NOT named `nixpkgs_ref` and deliberately NOT returned by [`ToolArtifact::nixpkgs_ref`]: that accessor drives `group_by_ref` and the daemon's attr path on 8080, and a revision leaking there routes a ref that is not in `ALLOWED_REFS` — the 2026-07-31 403 storm.

      store_pathrequired
      NixStorePath
    • object

      kindrequired

      string

      "store_path"

toolrequired

string

Canonical tool name (aliases resolve to it), or the raw name for a passthrough installable.

variant

string | null

Distribution/vendor where the tool has one (`temurin`, `zulu`, …).

versionrequired

string

The concrete version. **Open vocabulary, deliberately `String`** — the set of version strings is unbounded by construction, so this is the documented exception to the closed-set-DTO-enum rule.

DistLayout

string

How a dist archive unpacks into the tool-cache root.

"strip_one""flat"

StorePathArtifact

object

The `store_path` artifact's payload, with its invariants enforced. Two of them, and neither was a type before: 1. **`store_path` is a well-formed store path UNDER THE REAL STORE.** It deserializes straight out of `workflow_runs.toolchain_manifest` jsonb and [`ToolchainManifest::has_store_path`] is the agent's whole authorization surface, so as a raw `String` this answered "recorded" for anything that byte-matched a recorded entry — and left the grammar as the daemon's job alone rather than a property of the value. It takes BOTH halves to be that claim, and only the first was a type before: [`NixStorePath`] carries the GRAMMAR and is root-agnostic on purpose (the daemon's root is env-configurable and its own tests point it at a directory of their own), so `/etc/<hash>-passwd` is a shaped path and that type accepts it by design. [`StorePathArtifact::new`] pins the ROOT to [`NixStoreRoot::default`], on construction and on deserialize alike, which is what makes a recorded artifact naming anything outside the real store unconstructable. 2. **`bin_output` is one of `outputs`.** The `deno` → `denort` lesson is that which output carries `bin/` is index-ANSWERED, never guessed, and an answer naming an output the derivation did not have is not an answer. That was prose in a doc comment; it is checked here. `revision` stays an unvalidated `String` on purpose, and the asymmetry is the point: it is provenance only. No decision is made on it — that is exactly why [`ToolArtifact::nixpkgs_ref`] does not return it — so a malformed revision misleads a reader without routing anything, while a malformed store path is an authorization answer.

bin_outputrequired

string

Which output `store_path` is. Index-answered, never guessed — the `deno` → `denort` lesson says a name heuristic is not enough. A member of `outputs` by construction.

outputsrequired

array

Every output the derivation had, for provenance only.

items

string

revisionrequired

string

The revision the index chose (ADR 0101 §2a records it "beside the store path"). Deliberately NOT named `nixpkgs_ref` and deliberately NOT returned by [`ToolArtifact::nixpkgs_ref`]: that accessor drives `group_by_ref` and the daemon's attr path on 8080, and a revision leaking there routes a ref that is not in `ALLOWED_REFS` — the 2026-07-31 403 storm.

store_pathrequired

NixStorePath

string

An absolute, well-formed store path — `<root>/<hash>-<name>` for SOME valid [`NixStoreRoot`] — validated on construction AND on deserialize. **The grammar is a property of the VALUE, not a step some caller remembers.** `ToolArtifact::StorePath` is deserialized straight out of `workflow_runs.toolchain_manifest` jsonb and is the agent's whole authorization surface (`ToolchainManifest::has_store_path`), so a raw `String` there would let `/etc/passwd` answer "recorded" the moment it byte-matched a recorded entry. Validating in `Deserialize` too is the half that matters: an invariant that holds only for freshly-built values is not an invariant, and every value on this path arrives from a database. Deliberately ROOT-INDEPENDENT: whether a path is under the root *this* process serves is [`store_path_leaf`]'s question, and that root is env-configurable. This type answers the other one — is it shaped like a store path at all. Which means the grammar alone is NOT the authorization answer, and the doc must not be read as claiming it is: `/etc` is a root [`NixStoreRoot`] admits, so `/etc/<hash>-passwd` is a well-formed value of this type. Pinning a RECORDED artifact to the real store is [`crate::model::toolchain::StorePathArtifact::new`]'s clause, one layer up, where the recording happens.

NixStorePath

string

An absolute, well-formed store path — `<root>/<hash>-<name>` for SOME valid [`NixStoreRoot`] — validated on construction AND on deserialize. **The grammar is a property of the VALUE, not a step some caller remembers.** `ToolArtifact::StorePath` is deserialized straight out of `workflow_runs.toolchain_manifest` jsonb and is the agent's whole authorization surface (`ToolchainManifest::has_store_path`), so a raw `String` there would let `/etc/passwd` answer "recorded" the moment it byte-matched a recorded entry. Validating in `Deserialize` too is the half that matters: an invariant that holds only for freshly-built values is not an invariant, and every value on this path arrives from a database. Deliberately ROOT-INDEPENDENT: whether a path is under the root *this* process serves is [`store_path_leaf`]'s question, and that root is env-configurable. This type answers the other one — is it shaped like a store path at all. Which means the grammar alone is NOT the authorization answer, and the doc must not be read as claiming it is: `/etc` is a root [`NixStoreRoot`] admits, so `/etc/<hash>-passwd` is a well-formed value of this type. Pinning a RECORDED artifact to the real store is [`crate::model::toolchain::StorePathArtifact::new`]'s clause, one layer up, where the recording happens.