Writing a pack¶
A pack is a container image that evaluates a system and reports what happened.
It is the only part of the pipeline that talks to the system under test. See Packs for what a pack looks like from the plan's side, and Item records for the record format in detail.
Contract¶
- Ship
manifest.yamlat/app/manifest.yaml. - Accept
--systems-paramsand--test-params, both JSON strings. - Write one JSON Lines record per item to
/output/items.jsonl. - Exit 0 on a completed run. A non-zero exit means the run failed, not that the system under test scored badly.
- Accept
--output-dir, defaulting to/output. This is what lets a pack author run the pack outside a container while writing it.
The container runs as the user who invoked Touchstone
Not as root, and not as the image's USER. A bind mount on Linux keeps host
ownership, so a pack running as anyone else cannot write to /output. Do not put a
USER line in your Dockerfile expecting it to apply.
Results go in /output, never to stdout
Stdout is logs. It is not captured into the bundle, and a pack that prints a request prints the credential with it.
packs/example_pack/ is a working pack that follows all of this in about seventy lines of
standard library. Copy it.
Manifest¶
name: "example_pack"
version: "1.0"
description: "What this measures."
input_systems:
- name: "system_under_test"
type: "llm_api"
required: true
input_schema:
- name: "max_items"
type: "integer"
required: false
emits_items: true
strata:
- name: "language"
values: ["en", "pcm", "ha", "yo", "ig"]
network:
egress: ["api.openai.com"]
network.egress is a declaration a security reviewer can read. Empty means the pack
reaches nothing, and that is enforced: the container runs with no network at all.
A non-empty allowlist is enforced. The pack runs on a Docker network created
--internal, which has no route off the host, and a squid sidecar attached to both that
network and the bridge is the only way through. It allows CONNECT to the declared hosts
and denies everything else.
Containment does not depend on your pack. It is told about the proxy through
HTTPS_PROXY and the usual spellings, and that is a courtesy rather than the control: a
pack that ignores those variables is on a network with nowhere to go. The internal network
is what makes the answer irrelevant.
The proxy never terminates TLS. It reads the hostname off the CONNECT line and the
bytes stay opaque, so it is never trusted with your API keys. That is the constraint the
design is built around rather than a limitation of it.
Declare hostnames and nothing else. No scheme, no port, no path, no wildcard, and no bare
IP address: dstdomain matches names rather than addresses, so an address would be
accepted and then match nothing, and a denial with no explanation is worse than a refusal
with one. A host that is not a hostname is refused before anything starts. dstdomain
example.com matches that host and not its subdomains, so a pack that needs a subdomain
declares the subdomain.
The bundle records what happened. environment.json carries egress_enforced: true, and
every request the pack made, allowed or denied, is written to <run_id>.egress.log beside
its records. A denial in there is a finding rather than an error: it says the pack
tried to reach a host it never declared.
--allow-unenforced-egress is a downgrade of something the backend can now do. It runs the
pack on the ordinary bridge network with the whole internet available, and records
egress_enforced: false so a reader knows the pack was not contained. Use it to develop
against a real API. It is not a way to produce evidence for anyone.
strata is declared so a plan can be checked against it before anything runs. The
values are yours. Touchstone never interprets them, which is why a pack for any
market works without a change to the engine.
Item records¶
| Field | Purpose |
|---|---|
item_id |
stable across runs, joins re-analyses together |
stratum |
free-form dimensions, grouped by the estimator |
outcome |
booleans, become rates with a denominator |
score |
continuous measures, become means with intervals |
confidence |
optional, enables calibration and confident-and-wrong |
cost |
tokens, latency |
trace_ref |
path to the full prompt and response |
replicate |
which repeat this is |
Emit the observation, not the average. Touchstone computes every statistic from these
records so that a reader can recompute them from the bundle without trusting the pack.
touchstone estimate turns booleans into rates with a Wilson interval and scores into
means with a BCa bootstrap interval, grouped by whichever stratum keys are asked for.
A confidence is a claim about one particular outcome, and only the pack knows which, so
the manifest names it:
freeze reads that from the image and pins it into the lock, so what was calibrated is
part of the frozen plan. A pack that declares nothing is never calibrated, because an ECE
binned against an unrelated boolean is a meaningless number that reads as an authoritative
one. estimate --calibrate <outcome> overrides the declaration for re-analysis.
Do not emit pack_id on a record. The harness stamps it when it merges the per-unit files
and overwrites anything found there: a pack that could name itself could name another one,
and every rate downstream is grouped by that field.
Summary-only packs¶
A pack that cannot emit items sets emits_items: false and writes metrics to
/output/result.json. Those metrics are accepted, tagged summary_only, carry no
interval, and are capped when graded. Use it to wrap a framework you do not control.