neosourceDocs
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

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

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

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_tokenrequired

string

Per-job ephemeral `GITHUB_TOKEN`. The runner sets `secrets.GITHUB_TOKEN` to this value and exposes it as `$GITHUB_TOKEN` to every step.

jobrequired

WorkflowRunJobResponse

object

compile_cache
definitionrequired

object

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

enqueued_atrequired

integerint64

executed_on
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

repo_idrequired

string

run_idrequired

string

runs_on

array

Labels the job's `runs-on:` requires (a Nix job reports `["nix"]`).

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

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 runner must use its compiled default. Defaulted for wire compatibility with older servers.

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

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

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, 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
  • null

  • 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

trigger_commit

string | null

40-char git hex of the exact commit that triggered the run (the pushed tip / PR head) — 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.

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.

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
  • null

  • 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"

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"

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, and would leave the grammar as the daemon's job alone. 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, and would leave the grammar as the daemon's job alone. 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.