The smoke suite a module runs against itself. Derived from the checklist at the
bottom of module-surface.md, expanded into checks that name what they
exercise, how, and what counts as passing.
Until a runnable suite exists per language, this table is the contract. A module claiming conformance states which rows it passes.
Audience: someone building a module and wanting to know whether it is done, or reviewing one and wanting to know what to try.
How to read the status column#
The runner column records versable-runner (services-api) as of 2026-08-17.
It is the most complete instance and still fails closer to half of this, which
is the point of writing the table down.
- pass: verified, by a live call or by reading the code that decides it
- fail: verified absent or wrong
- n/r: not runnable without a mutating call, which this pass did not make
Live probes were unauthenticated GETs against
https://services-api-gvpyoxqxja-uc.a.run.app. No POST was issued, so every
row needing a job creation is n/r rather than assumed.
Witnesses, added 2026-08-18, and why they do not move the tally#
services-api gained a test suite (tests/, 9 files, 59 tests) at f46d894.
Run in a scratch copy of the repo, all 59 pass in 0.6 s. Rows below cite a
witness test where one exists.
A witness is not a pass. This document's own closing section requires a
conformance check to run against a deployed module over HTTP, because "a module
that passes in-process and fails deployed has passed nothing". Every one of
those 59 tests runs in-process against a FastAPI test client. So a witness
raises confidence about the code, changes no verdict, and the tally stands
unchanged at 11 pass, 20 fail, 11 n/r of the original 42 (the earlier
13/19/10 in this paragraph was a stale count; the table is the truth). Reading a witness as a pass
re-creates the [collect-not-run] trap the closing section warns about.
What the 2026-08-18 pass did change is two rows whose stated REASON was wrong
after f46d894, B3 and B6, corrected below.
A. Health, identity, discovery#
| id | Exercises | How | Expected | runner |
|---|---|---|---|---|
| A1 | liveness | GET /health, no credential | 200 {status:"ok"} | pass (returns {"ok":true}; key is ok, contract says status) |
| A2 | liveness is not edge-reserved | route is not named /healthz | any name but /healthz on Cloud Run | pass |
| A3 | readiness round-trips the substrate | GET /health/deep with stats:read | 200, storage and queue round-tripped, effective non-secret config echoed | n/r (route exists and correctly 401s unauthenticated, app/observability.py:260; whether it round-trips the substrate needs a credential, same as A5) |
| A4 | readiness reddens on a broken substrate | point the bucket at a nonexistent name, restart, call /health/deep | non-200, naming storage | n/r (needs a redeploy) |
| A5 | config echo leaks no secrets | read /health/deep output | no secret values present | n/r (needs a credential) |
| A6 | deploy is verifiable without a credential | GET /build-info, no credential | 200 {commit, built_at, branch?, name, version, contract_version} | fail (404) |
| A7 | capabilities are machine-readable | GET /manifest, no credential | 200, schema per contracts/manifest.md | fail (404). Do not read tests/test_api.py:189 test_get_manifest as contradicting this: it exercises the per-job stored run-file envelope, reached under a job id, which is exactly the name collision module-surface.md renamed to /jobs/{job_id}/envelope. There is still no capability manifest route |
| A8 | capabilities are human-readable | GET /guide | 200, plain text | fail (404; the guide exists but is served at /usage, which collides with /usage/events; generated from the live registry, app/usage.py:177), witness test_usage_is_open_and_complete |
B. Auth and tenancy#
| id | Exercises | How | Expected | runner |
|---|---|---|---|---|
| B1 | job routes require a credential | GET /jobs, no credential | 401 | pass (401) |
| B2 | stats routes require a credential | GET /stats, no credential | 401 | pass (401) |
| B3 | credentials are per caller | two callers, two credentials, revoke one | the revoked one 401s, the other keeps working | fail, reason corrected 2026-08-18. Per-user vsk_ keys now exist and validate per caller (app/auth.py:44, witnesses test_jobs_api_accepts_valid_key, test_jobs_api_rejects_invalid_key), so the old reason, one shared password for every caller, is no longer the whole story. It still fails on two counts: RUNNER_API_PASSWORD is accepted beside the keys and carries no caller identity at all (app/auth.py:42), and revocation is delegated to prompt-mgmt behind a verdict cache, so the revoke-one half of this check is exercised by no test here |
| B4 | a job route rejects a missing tenant | POST /jobs with no tenant | 400 or 422 naming the tenant | fail (no tenant concept; meta is an unverified tag) |
| B5 | a job route rejects a wrong tenant | read a job under another tenant | 403 or 404, never the job | fail (same) |
| B6 | a credential from another environment is refused | present a dev credential to prod | 401, even with a valid signature | fail (no env claim; dev and prod share a service account. Still true after f46d894: vsk_<key_id>_<secret> carries no environment segment and nothing on the path checks one, apikeys/client.py:1) |
| B7 | internal routes reject a caller credential | POST /internal/tasks/process-item with a caller credential | 401 or 403, never 202 | n/r deployed (GET returns 405, so the route exists and is POST-only; the check needs a POST). In-process witnesses test_task_push_requires_oidc_in_public_mode, test_task_handler_requires_cloudtasks_header, test_fanout_task_requires_cloudtasks_header; the code verifies a Cloud Tasks OIDC token against the service URL as audience, app/auth.py:56 |
| B9 | a key from another env is refused as unknown | present a dev key from a keys or remote verifier to prod | 401 (never 403, which would confirm the key is valid elsewhere) (caller-keys.md) | n/r (versable-runner runs no keys verifier; its vsk_ keys carry no env, see B6) |
| B10 | an expired key is refused promptly | present a key past expires_at | 401 within one request of expiry | n/r (vsk_ verdicts carry no expiry) |
| B11 | a revoked key is refused within the ceiling | revoke, then present within 60 s | 401 within 60 s | n/r (revocation is prompt-mgmt's; APIKEYS_CACHE_TTL_S defaults 60 with no ceiling, lib/config/__init__.py:140) |
| B12 | a key never appears in logs | make one request, grep the log | the presented key absent | n/r (needs log access) |
| B13 | a key in a query string is refused | GET /jobs?token=<key> | 401, logged as a misuse | n/r (needs a live call) |
| B14 | key health is visible | GET /health/deep | active-key count and soonest expiry present | n/r (needs a credential; /health/deep exists per A3) |
| B8 | internal routes accept platform identity only | queue delivers with OIDC | 2xx | pass by inspection (app/auth.py:56,70, audience is the service URL), witness test_task_push_requires_oidc_in_public_mode |
C. Submit#
| id | Exercises | How | Expected | runner |
|---|---|---|---|---|
| C1 | submit is O(1) in items | POST /jobs with 5,000 items, time it | returns under a second | n/r (design is O(1): two blobs and one fanout task, docs/runner-service.md:19-22) |
| C2 | no running before expansion | poll GET /jobs/{job_id} immediately after C1 | accepted or expanding, never running | n/r (enqueuing exists, app/jobs.py:148; the name differs from the contract's expanding) |
| C3 | repeated client_job_id is idempotent | POST /jobs twice with the same client_job_id | second returns 200 with the first job, no second job | fail (no client_job_id concept in the surface) |
| C4 | unknown capability fails fast | POST /jobs with a bogus capability | 422, nothing persisted | n/r deployed; in-process witnesses test_unknown_method_rejected_at_submit, test_simple_job_unknown_method_422. Note the module's unit is a method, not a capability |
| C5 | validation errors name each problem | POST /jobs with two bad fields | 422 with a details array of length 2 | n/r, and the suite does not answer it: test_invalid_run_file_shapes asserts the 422 status on four bad shapes and never inspects a details array |
| C6 | over-max items is refused | POST /jobs above the manifest max_items | 422 | fail (no manifest, so no declared ceiling) |
| C7 | over-ceiling is throttled, not failed | exceed a per-caller ceiling | 429 with Retry-After | fail (no ceilings, row 21) |
D. Reading results#
| id | Exercises | How | Expected | runner |
|---|---|---|---|---|
| D1 | every list is paginated | GET /jobs, GET /jobs/{job_id}/outcomes | limit, offset or cursor, and next_* present | pass (/results takes ?offset=&limit=, 1..1000, and returns next_offset, docs/runner-service.md:59), witness test_results_pagination |
| D2 | max page size is declared | compare ?limit= behaviour to the manifest | over-max clamps or 422, and the max is in the manifest | fail (limit is enforced at 1000 but no manifest declares it) |
| D3 | one outcome is fetchable alone | GET /jobs/{job_id}/outcomes/{item_id} | 200, one outcome, no full download | fail (no per-item route; /results is the only path) |
| D4 | outcome carries the required fields | read any outcome | item_id, type, attempts, duration_ms, usage_ref all present; plus variant when the capability declares variants, and reference_versions when it declares reference_data (module-surface.md § Outcomes) | fail in part (duration and attempts live in a separate usage/{idx}.json sidecar, not on the outcome; no type discriminator, success and error are different paths) |
| D5 | judged outcomes carry confidence | read a result or needs_review | confidence and judged_by present | fail (no needs_review type at all) |
| D6 | error codes are enumerated | compare error_type values to the manifest | every observed code is listed | fail (no manifest; codes such as RESEARCH_QUALITY_LOW exist but are undeclared) |
| D8 | the per-item log is readable | GET /jobs/{job_id}/outcomes/{item_id}/log | 200, paginated structured lines, every attempt (canon/07) | n/r (needs a credential and a finished item; the route is absent in versable-runner, so expected fail once run) |
| D9 | declared outputs are served | GET /jobs/{job_id}/outputs and GET /jobs/{job_id}/outputs/{name} for each output the manifest declares | list of transforms, then rows or a signed artifact URL, ?format= only where formats allows (canon/15) | n/r (no manifest, no declared outputs; /results serves a fixed shape) |
| D7 | filtering by type works | GET /jobs/{job_id}/outcomes?type=error | only errors | pass in spirit (/jobs/{job_id}/errors exists; the contract's shape differs) |
E. Lifecycle#
| id | Exercises | How | Expected | runner |
|---|---|---|---|---|
| E1 | cancel is accepted and idempotent | POST /jobs/{job_id}/cancel twice | 202 both times | n/r (route exists, app/api.py:153). test_cancel_marks_job witnesses one cancel and its effect, but it calls cancel ONCE, so the idempotent half is still unexercised |
| E2 | cancel produces a defined end state | cancel a running job, then read it | cancelled; in-flight items settled, unstarted skipped | n/r (marker blob plus pending-task delete, in-flight finishes, app/store.py:220-224; "unstarted are skipped" is not represented as an outcome type) |
| E3 | a dead worker does not strand a job | kill the worker mid-item | the item is redelivered and the job finishes | n/r (structural: a dead task is redelivered by the queue) |
| E4 | retry budget is bounded and owned by the handler | force a retryable failure | at most max_attempts attempts, then a terminal error outcome | pass by inspection (app/jobs.py:337-338) |
F. Callbacks#
| id | Exercises | How | Expected | runner |
|---|---|---|---|---|
| F1 | a callback is delivered | submit with callback_url, wait | at least one POST to the URL | fail (poll only, row 27) |
| F2 | the callback is signed | inspect X-Foundry-Signature | valid HMAC over the body with the caller's secret | fail |
| F3 | the callback carries an event id | inspect X-Foundry-Event-Id | present and unique per event | fail |
| F4 | delivery is at-least-once with a dead letter | make the receiver fail | retried with backoff, then dead-lettered with a count | fail |
G. Local and safety#
| id | Exercises | How | Expected | runner |
|---|---|---|---|---|
| G1 | the module runs with zero env | unset everything, start it | it starts | pass (every config var has a default, lib/config/__init__.py:110-129). Strengthened 2026-08-18: tests/conftest.py sets no environment variables, and all 59 tests import and exercise the app, so a zero-env import runs on every pass; witness test_registry_has_exactly_supported_methods |
| G2 | an in-process mode exists | set the dispatch mode to local, submit a job | it runs with no queue | pass (RUNNER_DISPATCH=local, app/dispatch.py:131-132,174), witness test_submit_and_complete_local_dispatch |
| G3 | the local verifier refuses a public URL | start with the local verifier and a public service URL | refuses to start | fail (no verifier concept; nothing couples auth mode to reachability) |
| G4 | secrets are injected by reference | inspect the deployed service | no secret values in the service spec | pass (8 secrets, all valueFrom, verified in live config) |
H. The manifest against the behaviour#
A manifest is a promise. These rows are the ones that read it back and check
the module keeps it, so none of them can run until a module serves /manifest
(A7). Every row here is n/r for versable-runner for that one reason, and they
do not enter the tally above until the route exists.
| id | Exercises | How | Expected | runner |
|---|---|---|---|---|
| H1 | every declared route answers | for each entry in surface.routes, issue its <METHOD> <path> with a valid credential and a real job id | no 404 and no 405 on any declared route; a route that needs a body may answer 4xx on validation, never "not found" | n/r (no /manifest, so there is no declared route list to enumerate) |
| H2 | no undeclared route is reachable | compare the served OpenAPI or route table to surface.routes | every reachable job or stats route appears in the manifest | n/r (same). Note versable-runner would fail this today: it serves a per-job stored envelope and a guide at /usage, neither declared anywhere |
| H3 | every honoured setting is declared | submit with each key in the capability's settings, then with a key absent from it | a declared key changes behaviour; an undeclared key is refused or ignored, never silently honoured | n/r (no manifest; the module honours max_attempts and item_timeout_s with nothing declaring them) |
| H4 | a variant default is a real option | read variants.<dimension>.default and compare it to that dimension's options | the default is a member of its own options | n/r. This is the membership rule contracts/manifest.md § Validation assigns to conformance rather than to the schema, because JSON Schema cannot express one property being a member of another |
| H5 | a log default is a real level | read log.default and compare it to log.levels | the default is a member of levels, and info is present | n/r (same reason as H4) |
| H7 | params schemas pass the kit's form lint (ADVISORY, never failing) | run lintSchema from @versable-git/ui over each capability's params | ok: true, or a refusal list the module has chosen to accept (owner: the contract stays wide, lint is guidance) | advisory, not-runnable until the kit publishes the lint (foundry-2) |
| H6 | the authoritative judge is real | read confidence.authoritative | it is self only when confidence.self is true, otherwise a member of confidence.judges | n/r (same reason as H4; the schema types the field as a string and stops there) |
Where versable-runner stands#
The result column is versable-runner's scorecard, not a property of the checks.
A row reads n/r because running it against a deployed service would need a
credential, a mutating call, or a redeploy, not because the check is
unrunnable in principle. A module built on the runner template runs every row
in-process, where pointing a bucket at a nonexistent name is a config change
rather than a deploy, so its acceptance bar is every row executed and every row passing.
Of the original 42 checks: 11 pass, 20 fail, 11 not runnable without a
mutating call or a redeploy. Eight rows were added on 2026-08-18 (B9 to B14, D8,
D9), all n/r for versable-runner, so the table now holds 50 rows outside
section H: 11 pass, 20 fail, 19 n/r. (A: 8 rows, B: 14, C: 7, D: 9, E: 4,
F: 4, G: 4. Anyone adding a row should re-tally rather than trusting this line;
an earlier version of it was wrong on three of the four numbers.)
The table is the floor, not the ceiling. It stops at the job core plus
auth, callbacks and local safety. It does not reach: outputs beyond D9's
existence check, the review queue's grouping (?group_by=reason), usage
events' shape and idempotency (usage-event.md), reference-data version
reporting, cache behaviour (canon/06), or any capability's own quality. A
module supplies its own eval group for the last one (see below); the rest are
rows to add when a module serves them.
Module-supplied checks (group M, out of tally). A module ships its own
capability evals (patterns/05, the fixtures and judges of its payload) as a
group this CLI runs after the contract rows and reports separately; they are
never counted in the contract tally, because they measure the capability, not
conformance.
foundry-runner's own column is written by the CLI against the local runner
(runner step 1, ../PLAN.md forge-1) and lives in ../instances/foundry-runner.md,
not here; this table's result column stays versable-runner's scorecard. A8 moved from pass to fail when
the prose guide became /guide: the guide exists and is good, but it answers
at /usage, and a route the contract does not name is not a passing check.
The failures cluster, and the clusters are the same ones canon calls open: everything in F (callbacks), most of B (per-caller identity and tenancy), and the manifest-dependent rows across A, C and D. Nothing in the fail column is a bug in versable-runner. They are places the contract asks for something the instance predates.
Two rows are near-misses worth fixing cheaply. A1 returns {"ok":true} where
the contract says {status:"ok"}; that is a one-line change. C2 reports
enqueuing where the contract says expanding; also one line, and worth
aligning before other modules copy the name.
One observation the table does not capture: the first GET /health timed out
after 15 seconds and the second returned instantly. The service scales to zero
and its cold start exceeds a naive client timeout. Any conformance runner needs
a warm-up call before it times anything, or C1's "under a second" will fail for
a reason that has nothing to do with submit.
How this will run#
Not built. The intended shape, so nobody invents a different one:
A single executable per language, foundry-conform <base_url>, taking a
credential and a tenant from the environment, running the table top to bottom,
and emitting one line per check plus a JSON summary. Checks that mutate
(everything marked n/r above) run only with --allow-writes and against a
non-prod base URL, refusing to run otherwise.
Three properties matter more than coverage:
- It runs against a deployed module over HTTP, not against an import. A module that passes in-process and fails deployed has passed nothing.
- Each check names the contract line it enforces, so a failure is a pointer into this tree rather than a number.
- It distinguishes fail from not-run. A suite reporting "32 of 50" without
saying that 10 of those never executed is the
[collect-not-run]trap wearing a percentage.
Until it exists, a module states its row-by-row status in its own README and this table is what it states them against.