TfP Docs

Custom Checkers

Status: live. The reference format, the public base image and harness, local testing under Docker, registration, provisioning of one runner per runtime digest, execution on TfP and per-Implementation conformance are implemented and running; see Status at the end for what was verified and when.

What a custom checker is

A sandbox stage of a verifier is computed by a program TfP runs in isolation. That program is one of TfP's builtins (tfp-builtin/sat-assignment-check:1, tfp-builtin/json-schema-check:1), a script checker (one Python file beside the Contract, run on TfP's own runtime; the shortest path when no builtin fits), or a custom checker: a public container image you publish, that TfP runs under the same run protocol and holds to the same rules. Reach for an image when the check needs more than one standard-library Python file: packages, a solver, another language, a large toolchain. You write the image; TfP decides whether it is trustworthy enough to speak for the Contract.

Naming the image

A stage names its checker in verifier-contract.json:

"implementation": "docker.io/<user>/<repo>@sha256:<64 hex>"

The accepted form is <registry>/<repository>@sha256:<64 lowercase hex>. docker.io is the default registry (docker.io/<user>/<repo> and <user>/<repo> mean the same thing; an official image is docker.io/library/<repo>). Other public registries work in the same form (ghcr.io/<org>/<repo>@sha256:…); the schema is registry-agnostic and Docker Hub is the recommended home. The digest is the only immutable name an image has, so tags are refused at registration: docker.io/me/checker:v3 is a lint error, because whatever v3 points at tomorrow is not what the Contract was reviewed against. Tags are fine while you build and push; pin the digest when you write the Contract.

To find the digest of an image you have pushed:

docker buildx imagetools inspect docker.io/me/checker:v3
docker inspect --format '{{index .RepoDigests 0}}' docker.io/me/checker:v3

Both print docker.io/me/checker@sha256:…; paste that whole string. If you built for several platforms, the digest of the multi-platform index is what you pin: TfP preserves the digest you supplied, resolves the linux/amd64 child, records that child as the runtime digest, and provenance names exactly what executed.

The image must be publicly pullable. TfP may identify itself to the registry with its own read-only credential to obtain reliable access to public images; author registry credentials are neither required nor accepted, and a private image is rejected with "public images only".

Building the image without a Docker daemon (Build Cloud)

Cloud agents (and any machine without Docker running) build and push through Docker Build Cloud, which needs only the docker CLI and Buildx ≥ 0.37. The CLI wraps the whole flow:

export DOCKER_ACCESS_TOKEN=…   # a Docker Hub access token; never in a file
export DOCKER_ACCOUNT=tokensforprogress CLOUD_BUILDER_NAME=tfp
tfp verifier build <checker-dir>            # inside the package: pins the digest into verifier-contract.json
tfp verifier build <checker-dir> --json     # anywhere: prints { image_ref, digest, … }

tfp verifier build needs a Dockerfile in <checker-dir>; picks a builder (an existing cloud-driver builder from docker buildx ls, else one created from the three variables above or --builder <account>/<name>, else the local daemon); builds for linux/amd64, pushes to tokensforprogress/tfp-verifiers (--repository, DOCKER_REPOSITORY) under a throwaway tag (--tag); reads the digest from Buildx's metadata file; confirms the image is anonymously pullable from Docker Hub; and, when a verifier-contract.json sits beside the checker directory (or --contract <path>), writes docker.io/<repository>@sha256:… into the sandbox stage's implementation (--stage <key> if there are several; --limits memory_mb=6144,vcpu=1,timeout_seconds=300 writes limits too). --dry-run prints the commands instead of running them.

What it runs, from a sandbox with no daemon:

docker buildx version                      # needs 0.37+ with the `cloud` driver
printf '%s' "$DOCKER_ACCESS_TOKEN" | docker login --username tokensforprogress --password-stdin
docker buildx create --driver cloud --use tokensforprogress/tfp || docker buildx use cloud-tokensforprogress-tfp
docker buildx build --platform linux/amd64 --push \
  --tag tokensforprogress/tfp-verifiers:<campaign>-<checker>-$(date +%Y%m%d%H%M%S) \
  --metadata-file meta.json <build-context>
python3 -c 'import json; print(json.load(open("meta.json"))["containerimage.digest"])'

Pin docker.io/tokensforprogress/tfp-verifiers@sha256:<digest> in the Contract; the tag is a throwaway build name. The credential lives in your environment, never in a Contract, Dockerfile, committed file or log (the CLI passes it to docker login over stdin and never prints it). Do not stand up a registry, a CI pipeline or hand-assembled OCI manifests to work around a missing daemon.

The TfP verifier base image

The recommended path in the beta is to build on one blessed base image:

tokensforprogress/verifier-python:<semver>

published on Docker Hub as a multi-arch image (linux/amd64 and linux/arm64). Production runs linux/amd64; tfp test runs your image under linux/amd64 as well, even on Apple Silicon, so a platform mismatch surfaces locally rather than on the server. Python is the only language the base supports for now; the layout is structured so that later bases (verifier-base, verifier-node) reuse the same harness unchanged.

Your image is FROM the base, copies your checker in, and sets CMD to the command that runs it. The base fixes ENTRYPOINT to /tfp/harness; your CMD is the checker command the harness spawns:

FROM tokensforprogress/verifier-python:0.1.0
COPY checker.py /checker/checker.py
CMD ["python", "/checker/checker.py"]

Build and push it, pin the digest, and you are done with packaging. The current base is tokensforprogress/verifier-python:0.1.0, index digest sha256:a8752ba652a6650bc9f6b96063a5f4428858ea0e145e65b2d0a9c58ccb182cdd (see Status for the per-platform digests). Anything the checker needs (pip packages, data files) is installed in the image at build time; there is no network at run time.

What the checker does

The checker is an ordinary program. It only:

It does not listen on a port, parse arguments, talk to a registry or know anything about TfP beyond those paths.

What the harness does

/tfp/harness is the base image's entrypoint and owns everything between the trusted runner and your program:

The runner Worker stays authoritative for interpreting result.json: the harness reports what happened (exit code, timeout, log, result bytes, evidence), and the Worker decides what it means for the stage.

What the base image is not

The base image is not the security boundary. Isolation is the Cloudflare runtime policy: enableInternet=false, no TfP secrets in or reachable from the author's image, resource ceilings, and per-run isolation. The harness makes the checker easy to write and its results easy to interpret; it does not make a hostile image safe, and the runner does not rely on it for that.

The run protocol

Whether you use the base image or build your own image that speaks the protocol, this is what a run looks like from inside the container. This section is the author-facing contract; the harness that implements it is not something a checker needs to read.

Inputs (read-only)

/input/manifest.json              what this run is; read it for every path
/input/artifacts/<role>/<file>    the Submission's Artifacts, one directory per role
/input/reference/<key>/<file>     the stage's selected reference inputs, one directory per key

manifest.json:

{
  "version": "tfp.verification-manifest/v0.1",
  "run_id": "vrn_…",                       // "cfx_…" during a conformance (fixture) run
  "verifier_contract_id": "vct_…",
  "stage_key": "structure_check",
  "problem_revision_id": "prv_…",
  "submission": { "id": "sub_…", "summary_md": "…" | null, "summary_included": true },
  "artifacts": [
    { "role": "document", "artifact_id": "art_…" | null, "path": "/input/artifacts/document/translation.json",
      "filename": "translation.json", "content_hash": "sha256:…", "size_bytes": 114, "media_type": "application/json" }
  ],
  "reference": [
    { "key": "document_schema", "path": "/input/reference/document_schema/document_schema.json",
      "filename": "document_schema.json", "content_hash": "sha256:…", "size_bytes": 420, "media_type": "application/json" }
  ],
  "prior_stage_results": [ { "stage_key": "…", "kind": "sandbox" | "review" | "external", "result": "verified" | "refuted" | "inconclusive" | "error" } ],
  "result_kind": "pass_fail" | "score",
  "limits": { "vcpu": 1, "memory_mb": 512, "disk_mb": 256, "timeout_seconds": 120, "network": "none" }
}

Output

Exit 0 and write /output/result.json:

{ "result": "verified" | "refuted" | "inconclusive", "reason": "one sentence, ≤4000 chars", "score": 0.83 }

What TfP makes of it

The checker The stage result
exit 0, valid result.json the result it wrote
exit non-zero error (a failure of the checker, never of the submission)
exit 0 but no, malformed, oversized (>64 KB) or extra-keyed result.json error
score present where not allowed, or missing on a verified score Contract error
evidence over 8 MB, or summary.json missing/invalid when the stage declares an evidence_schema error
still running at timeout_seconds inconclusive
any network access none exists (limits.network is none); a checker that needs it must embed what it needs in the image

Resources: what a checker asks for, what runs it

A checker's execution envelope belongs to its Implementation, never to the Contract: growing a checker's memory never revises a Contract or a Problem. Ask on the stage, beside implementation:

"implementation": "docker.io/<user>/<repo>@sha256:…",
"limits": { "memory_mb": 6144, "vcpu": 1, "disk_mb": 8000, "timeout_seconds": 300 }

Anything left out takes the default, and lint strips limits out of the Contract document (the Contract's hash does not change). Defaults: 0.25 vCPU, 1024 MB memory, 2048 MB scratch disk, 120 s wall clock, no network. Ceilings: 4 vCPU, 12288 MB, 20000 MB, 600 s; a request above them is a lint error, not a run-time surprise.

TfP maps the requested envelope onto the smallest runner shape that satisfies it. Shapes are Cloudflare Containers instance types; the same image may run under several Implementations at different shapes, and a larger runner serves any smaller ask:

shape vCPU memory disk
basic (default) 0.25 1 GiB 4 GB
standard-1 0.5 4 GiB 8 GB
standard-2 1 6 GiB 12 GB
standard-3 2 8 GiB 16 GB
standard-4 4 12 GiB 20 GB

A first run on a fresh shape includes pulling the image (a 3 GB Lean image takes about a minute); after that a warm slot answers at once.

When the container dies

what the platform reports during conformance on a real Submission
the container was killed with exit code 137 (the memory limit) the Implementation is non-conformant under its declared limits: failed, with the fixture, the limits and the remedy (publish a new Implementation asking for more) the stage is inconclusive ("Resource limit exceeded"), never refuted, never a checker error
the container exited or disconnected for another reason (runner_lost) operational: stays pending, retried with backoff; after 12 attempts it is settled failed with the last reason so nobody waits on a checker TfP cannot run the run retries; if it keeps happening the run ends error with the reason

TfP never guesses: a lost container with no exit code is reported as lost, not as out of memory.

Checker test cases (conformance fixtures)

Before TfP lets a checker judge anyone's work, it asks the checker questions it already knows the answers to. A Contract ships a few inputs with known outcomes — a solution everyone agrees is valid, one that is wrong in a specific way, one that is unreadable — and states what a correct checker must say about each (verified, refuted, inconclusive). The difference from a real run is exactly that: in a real run the answer is unknown and the checker's word is what counts, so it first has to earn that on cases whose answer is not in doubt.

The formal term is the Contract's conformance fixtures (verifier reference). Every sandbox stage carries at least one. TfP runs every fixture for your stage through your image and compares; one disagreement, or any error, and the checker does not conform.

Local test versus production

tfp test <dir> runs the same fixtures on your machine. What is shared with production differs by checker kind, and it is worth being exact:

Registration and lifecycle

How TfP reads the registry. Registration resolves the reference through the OCI distribution API once, ever: a pull token, then the manifest by digest, then the linux/amd64 child. Docker Hub rate-limits pull-token requests per source address, so TfP identifies itself to Docker Hub with its own read-only credential (the operator secrets DOCKERHUB_USERNAME and DOCKERHUB_PULL_TOKEN on the Worker; Cloudflare's container runtime is configured with a second read-only token through wrangler containers registries configure docker.io). That credential only raises TfP's own allowance: an author's image must still be publicly pullable, the credential never reaches an author, a Contract, a log or a checker, and no private image becomes visible through it. A registry that answers 429 or 5xx while TfP resolves an image is reported as registry_unavailable (with the registry's Retry-After when it gave one) after a few bounded retries; that is a statement about the registry, never about the image, and validating the same upload again is the whole remedy.

Units. A custom sandbox VerifierImplementation names exactly one author-supplied, digest-pinned image reference. That reference resolves to one linux/amd64 runtime digest (the same digest for a single-platform image; the matching child for a multi-platform index). Three units, kept distinct: the semantic unit is the VerifierImplementation; the author's provenance identity is the pinned reference and its digest, recorded exactly as written; the physical provisioning unit is the unique runtime digest. Many Implementations, across many Contracts and authors, and even two different indexes that resolve to the same linux/amd64 bytes, share one runner. TfP provisions one runner per unique runtime digest, never a duplicate, and never loses which reference an author actually pinned.

Registration is automatic. There is no register command. When validate_authoring_upload (tfp upload) validates a package whose stage names a custom image, the image is registered under the author's custom_verifier capability if it is not already known, and its provisioning is enqueued. A digest someone else already registered is reused. The reference must be digest-pinned; a multi-platform index is resolved to its linux/amd64 child and both digests are recorded.

Status. One read tool, get_sandbox_image_status { image }, and one command over it:

tfp verifier status <ref-or-digest>

report where an image stands. This is the whole author-facing surface for images beyond the package itself; it was chosen over an explicit register/status pair because validation already knows every image the package names and an author should never have to tell TfP twice.

Lifecycle.

registered ──▶ provisioning ──▶ available ──▶ conformance pending ──▶ passed
                    │                                   │
                    └──▶ provisioning failed            └──▶ failed

A failed Implementation can be re-run by an operator (for instance after a fixture was corrected); the verdicts of earlier versions are never rewritten. A package naming a custom image can materialize as ordinary work regardless of image state; a run reaching that stage waits for the image while it is provisioning or pending, and ends error if provisioning fails: an image TfP could not start is TfP's machinery failing, never a finding about the submission (a checker that runs out of time or memory is inconclusive, per the runtime matrix).

Runner architecture on Cloudflare

This section is the frozen design for how a registered image becomes executable. It is written against Cloudflare's Containers platform as documented on 2026-09-03; the facts that constrain it are cited so the design can be re-checked when the platform changes.

What the platform fixes. A container application has exactly one image, chosen when the application is deployed: the image is a property of the Durable Object class the application is attached to, and Worker code cannot pick a different image per request (image management, architecture). Instances are addressed by name and started on demand by trusted Worker code (getContainer(binding, name) then startAndWaitForPorts()), stop after sleepAfter idle seconds, and run with enableInternet = false when told to (container package). Bindings between Workers are fixed at deploy time; a Worker may call another Worker's URL with a shared secret. Public Docker Hub images can be named directly in an application's configuration; Cloudflare does not cache them. Containers require the Workers Paid plan (pricing).

Provisioning unit. One runner application per unique linux/amd64 runtime digest. A VerifierImplementation is the semantic unit; the author's pinned reference is the provenance record; the runtime digest is the physical one. Two Implementations, or two pinned references, that resolve to the same runtime digest share one runner; registering a reference whose runtime digest already has a runner provisions nothing.

The runner. A small Worker, named for the digest (tfp-runner-<first 12 hex of the digest>), exporting one container class with sleepAfter of about one minute, enableInternet = false, max_instances set to TfP's per-runner concurrency ceiling, and a fetch handler that requires a shared secret header, names the instance after the VerificationRun id, starts it, and forwards POST /run to the harness on its port. The runner holds only the runner secret. The author's image receives no environment, no credentials and no network.

The provisioner. A separate process from the request-serving Worker, because it needs an account-management credential (Workers Scripts Edit plus Containers Edit) that must not sit in the main Worker. It is driven by the provisioning queue: registering a new digest enqueues one job; the provisioner uploads the runner script with its container class, durable-object binding, migration and secret (Workers script upload API), reads the resulting durable-object namespace id, and creates the container application naming the author's public image by digest (the Containers applications API that wrangler deploy itself uses; it is not in the public API reference, so the provisioner treats it as unstable and records exact responses). It records the runner name and URL on the image and marks it available, or marks it failed with the platform's error. The main Worker is never redeployed. Until the provisioner is deployed on a paid account, the same steps are an operator script.

Data plane. A VerificationRun reaching a sandbox stage whose Implementation names a custom digest looks up the digest's runner, posts the job to the runner's URL with the shared secret, and the runner starts a uniquely named instance, forwards the job to /tfp/harness, collects the envelope, and destroys the instance before accepting a verdict. Retries never reuse its identity. The idle timer is a crash backstop, not the isolation boundary. Per-runner and global concurrency leases bound dispatch, together with max_instances. There is no autoscaler and no warm pool. A runner that cannot be reached, a harness that faults, or an instance that never comes up is an error for that run, never a finding about the submission.

Local mirror. tfp test runs the same image under Docker on linux/amd64 with the same harness protocol; the digest, manifest layout, fixtures, result schema and interpretation rules are shared, the execution environment is not.

Status

The whole path is live on tokensforprogress.org: validation resolves and registers a digest-pinned image, the provisioner (its own Worker with its own scoped credential) creates one runner per linux/amd64 runtime digest, and the tfp-runner data plane executes runs on it. Verified on 2026-09-04 on the Workers Paid plan with a public Docker Hub image pinned by digest: registered → provisioning → available in about five seconds, the runner answering /healthz with the protocol and refusing /run without the shared secret. An image that does not speak the harness protocol (that test image did not) makes every run on it an error with the reason recorded, never a verdict, which is the designed failure. An image that cannot be provisioned at all is provisioning_failed with the platform's answer verbatim, and an operator can re-enqueue it or record a hand-provisioned runner through /admin/sandbox-runners.

The base image tokensforprogress/verifier-python:0.1.0 is on Docker Hub as a public multi-platform image (published 2026-09-04, anonymous pull verified with an empty Docker config, FROM build verified from an empty directory with no repository access):

digest
index (linux/amd64 + linux/arm64) sha256:a8752ba652a6650bc9f6b96063a5f4428858ea0e145e65b2d0a9c58ccb182cdd
linux/amd64 (production) sha256:a48d475bd180e1a727e700054268527e00207e02c847f238e1f9f9bf8c0ebdde
linux/arm64 (Apple Silicon development) sha256:c5964b8ade87ad58f55805ddb59f990cbd21464356b92d38bbb226894dc665a4

The next step is the first end-to-end custom conformance run by an external author building FROM it.