Agent docs

Contract: conformance

The smoke suite a module runs against itself.

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#

idExercisesHowExpectedrunner
A1livenessGET /health, no credential200 {status:"ok"}pass (returns {"ok":true}; key is ok, contract says status)
A2liveness is not edge-reservedroute is not named /healthzany name but /healthz on Cloud Runpass
A3readiness round-trips the substrateGET /health/deep with stats:read200, storage and queue round-tripped, effective non-secret config echoedn/r (route exists and correctly 401s unauthenticated, app/observability.py:260; whether it round-trips the substrate needs a credential, same as A5)
A4readiness reddens on a broken substratepoint the bucket at a nonexistent name, restart, call /health/deepnon-200, naming storagen/r (needs a redeploy)
A5config echo leaks no secretsread /health/deep outputno secret values presentn/r (needs a credential)
A6deploy is verifiable without a credentialGET /build-info, no credential200 {commit, built_at, branch?, name, version, contract_version}fail (404)
A7capabilities are machine-readableGET /manifest, no credential200, schema per contracts/manifest.mdfail (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
A8capabilities are human-readableGET /guide200, plain textfail (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#

idExercisesHowExpectedrunner
B1job routes require a credentialGET /jobs, no credential401pass (401)
B2stats routes require a credentialGET /stats, no credential401pass (401)
B3credentials are per callertwo callers, two credentials, revoke onethe revoked one 401s, the other keeps workingfail, 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
B4a job route rejects a missing tenantPOST /jobs with no tenant400 or 422 naming the tenantfail (no tenant concept; meta is an unverified tag)
B5a job route rejects a wrong tenantread a job under another tenant403 or 404, never the jobfail (same)
B6a credential from another environment is refusedpresent a dev credential to prod401, even with a valid signaturefail (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)
B7internal routes reject a caller credentialPOST /internal/tasks/process-item with a caller credential401 or 403, never 202n/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
B9a key from another env is refused as unknownpresent a dev key from a keys or remote verifier to prod401 (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)
B10an expired key is refused promptlypresent a key past expires_at401 within one request of expiryn/r (vsk_ verdicts carry no expiry)
B11a revoked key is refused within the ceilingrevoke, then present within 60 s401 within 60 sn/r (revocation is prompt-mgmt's; APIKEYS_CACHE_TTL_S defaults 60 with no ceiling, lib/config/__init__.py:140)
B12a key never appears in logsmake one request, grep the logthe presented key absentn/r (needs log access)
B13a key in a query string is refusedGET /jobs?token=<key>401, logged as a misusen/r (needs a live call)
B14key health is visibleGET /health/deepactive-key count and soonest expiry presentn/r (needs a credential; /health/deep exists per A3)
B8internal routes accept platform identity onlyqueue delivers with OIDC2xxpass by inspection (app/auth.py:56,70, audience is the service URL), witness test_task_push_requires_oidc_in_public_mode

C. Submit#

idExercisesHowExpectedrunner
C1submit is O(1) in itemsPOST /jobs with 5,000 items, time itreturns under a secondn/r (design is O(1): two blobs and one fanout task, docs/runner-service.md:19-22)
C2no running before expansionpoll GET /jobs/{job_id} immediately after C1accepted or expanding, never runningn/r (enqueuing exists, app/jobs.py:148; the name differs from the contract's expanding)
C3repeated client_job_id is idempotentPOST /jobs twice with the same client_job_idsecond returns 200 with the first job, no second jobfail (no client_job_id concept in the surface)
C4unknown capability fails fastPOST /jobs with a bogus capability422, nothing persistedn/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
C5validation errors name each problemPOST /jobs with two bad fields422 with a details array of length 2n/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
C6over-max items is refusedPOST /jobs above the manifest max_items422fail (no manifest, so no declared ceiling)
C7over-ceiling is throttled, not failedexceed a per-caller ceiling429 with Retry-Afterfail (no ceilings, row 21)

D. Reading results#

idExercisesHowExpectedrunner
D1every list is paginatedGET /jobs, GET /jobs/{job_id}/outcomeslimit, offset or cursor, and next_* presentpass (/results takes ?offset=&limit=, 1..1000, and returns next_offset, docs/runner-service.md:59), witness test_results_pagination
D2max page size is declaredcompare ?limit= behaviour to the manifestover-max clamps or 422, and the max is in the manifestfail (limit is enforced at 1000 but no manifest declares it)
D3one outcome is fetchable aloneGET /jobs/{job_id}/outcomes/{item_id}200, one outcome, no full downloadfail (no per-item route; /results is the only path)
D4outcome carries the required fieldsread any outcomeitem_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)
D5judged outcomes carry confidenceread a result or needs_reviewconfidence and judged_by presentfail (no needs_review type at all)
D6error codes are enumeratedcompare error_type values to the manifestevery observed code is listedfail (no manifest; codes such as RESEARCH_QUALITY_LOW exist but are undeclared)
D8the per-item log is readableGET /jobs/{job_id}/outcomes/{item_id}/log200, 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)
D9declared outputs are servedGET /jobs/{job_id}/outputs and GET /jobs/{job_id}/outputs/{name} for each output the manifest declareslist 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)
D7filtering by type worksGET /jobs/{job_id}/outcomes?type=erroronly errorspass in spirit (/jobs/{job_id}/errors exists; the contract's shape differs)

E. Lifecycle#

idExercisesHowExpectedrunner
E1cancel is accepted and idempotentPOST /jobs/{job_id}/cancel twice202 both timesn/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
E2cancel produces a defined end statecancel a running job, then read itcancelled; in-flight items settled, unstarted skippedn/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)
E3a dead worker does not strand a jobkill the worker mid-itemthe item is redelivered and the job finishesn/r (structural: a dead task is redelivered by the queue)
E4retry budget is bounded and owned by the handlerforce a retryable failureat most max_attempts attempts, then a terminal error outcomepass by inspection (app/jobs.py:337-338)

F. Callbacks#

idExercisesHowExpectedrunner
F1a callback is deliveredsubmit with callback_url, waitat least one POST to the URLfail (poll only, row 27)
F2the callback is signedinspect X-Foundry-Signaturevalid HMAC over the body with the caller's secretfail
F3the callback carries an event idinspect X-Foundry-Event-Idpresent and unique per eventfail
F4delivery is at-least-once with a dead lettermake the receiver failretried with backoff, then dead-lettered with a countfail

G. Local and safety#

idExercisesHowExpectedrunner
G1the module runs with zero envunset everything, start itit startspass (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
G2an in-process mode existsset the dispatch mode to local, submit a jobit runs with no queuepass (RUNNER_DISPATCH=local, app/dispatch.py:131-132,174), witness test_submit_and_complete_local_dispatch
G3the local verifier refuses a public URLstart with the local verifier and a public service URLrefuses to startfail (no verifier concept; nothing couples auth mode to reachability)
G4secrets are injected by referenceinspect the deployed serviceno secret values in the service specpass (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.

idExercisesHowExpectedrunner
H1every declared route answersfor each entry in surface.routes, issue its <METHOD> <path> with a valid credential and a real job idno 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)
H2no undeclared route is reachablecompare the served OpenAPI or route table to surface.routesevery reachable job or stats route appears in the manifestn/r (same). Note versable-runner would fail this today: it serves a per-job stored envelope and a guide at /usage, neither declared anywhere
H3every honoured setting is declaredsubmit with each key in the capability's settings, then with a key absent from ita declared key changes behaviour; an undeclared key is refused or ignored, never silently honouredn/r (no manifest; the module honours max_attempts and item_timeout_s with nothing declaring them)
H4a variant default is a real optionread variants.<dimension>.default and compare it to that dimension's optionsthe default is a member of its own optionsn/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
H5a log default is a real levelread log.default and compare it to log.levelsthe default is a member of levels, and info is presentn/r (same reason as H4)
H7params schemas pass the kit's form lint (ADVISORY, never failing)run lintSchema from @versable-git/ui over each capability's paramsok: 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)
H6the authoritative judge is realread confidence.authoritativeit is self only when confidence.self is true, otherwise a member of confidence.judgesn/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.

@versable-git/ui · reference, canon, and method, read in place