The HTTP surface every Foundry module exposes. This is the normative part: a
module that exposes these routes with these shapes, and behaves as
canon/03 and canon/04 describe behind them, is conforming. Routes marked
required are the floor; standard routes are expected unless the
manifest says the module does not do that; optional routes are named so
that modules that offer them offer them the same way.
The shapes below are derived from versable-runner's surface
(src/services-api/app/api.py, docs/runner-service.md), which is the
most complete instance, with the tenant, caller, correlation, callback, and
manifest additions the canon requires. Where a versable-runner route differs,
the difference is noted, so upgrading it is a checklist.
Audience: anyone building a module or writing a client for one.
Conventions#
- JSON in, JSON out, UTF-8. Errors are
{error: {code, message, details?}}. Adetailsentry is ALWAYS{path, message}, whatever the code.pathnames the field in the document being judged: a request field forVALIDATION(params.template), and a manifest field forUNAVAILABLE(capabilities,identity.verifier), since the manifest is the document a conformance gap judges. The fix belongs inmessage, which is where an env var name goes. Elsewheredetails: [gaps]names what the entries are ABOUT and not their shape; one key carries one shape with an HTTP status that means what it says (400 bad request, 401 no or bad credential, 403 credential ok but not allowed, 404, 409 conflict, 422 validation, 429 over a ceiling, 5xx module fault).codeis a closed set, one per status:BAD_REQUEST400,UNAUTHENTICATED401,FORBIDDEN403,NOT_FOUND404,CONFLICT409,VALIDATION422,CEILING429,UNAVAILABLE503,INTERNAL500; a module never invents an envelope code (item-level codes live in the manifest'serror_types, not here). - Every request except health and the manifest carries
Authorization: Bearer <credential>and, for job routes, a tenant (X-Tenant-Idheader ortenantin the body; header wins).canon/02. - Every list is paginated:
?limit=(default and max stated in the manifest) plus?offset=or?cursor=, and the response carries the page, atotalwhere cheap, andnext_offsetornext_cursor. - Ids are opaque strings.
job_idis the module's;client_job_idanditem_idare the caller's and are always echoed back. - Timestamps are RFC 3339 UTC.
- Versioning: the manifest states the module version and the contract
version it conforms to. Breaking changes to a route bump the path prefix
(
/v2/jobs); additive changes do not.canon/11.
Routes#
| Route | Tier | Scope | What it does |
|---|---|---|---|
GET /health | required | none | liveness, 200 {status: "ok"}; on Cloud Run do not name it /healthz (edge-reserved) |
GET /health/deep | required | stats:read | readiness: round-trips storage and queue, echoes the effective non-secret config, reports the verifier in use |
GET /build-info | required | none | {commit, built_at, branch?, name, version, contract_version}; snake_case, one shape for modules and apps (versable-builder's app templates emit it since ba70a11; speedway's camelCase is the older witness); unauthenticated on purpose so a deploy can be confirmed without credentials |
GET /manifest | required | none | the machine-readable capability manifest (contracts/manifest.md): capabilities, param and item schemas, outcome schemas, error and review codes, ceilings, supported settings; carries an ETag and honours If-None-Match with 304, so an app that caches it refreshes with a cheap poll. A 200 from this route means a conforming manifest: a module that cannot conform in its environment (no payload registered, a verifier it does not implement) answers 503 {error: {code: "UNAVAILABLE", message, details: [gaps]}}, each gap naming its own fix, rather than serving a promise an app would build against |
GET /guide | optional | none | the manifest rendered as plain text for a person or an LLM agent; versable-runner serves this at /usage, which is a lapse because /usage/events is the usage route; /usage may alias /guide for one release |
POST /jobs | required | jobs:submit | create a job; O(1) in items; returns 202 {job_id, state: "accepted"} |
GET /jobs | required | jobs:read | list jobs for the tenant, newest first; filters: state, capability, created_after, created_before, updated_after, attribution.<key>=<value> (repeatable, ANDed), client_job_id; response {jobs: [status rows with counts and updated_at], count, total, offset, limit, next_offset, facets} where facets carries live counts per value for state, capability, and every attribution key the manifest marks facetable, computed under the applied filter. This route is a reconciliation and debugging surface: an app's jobs page lists the app's own jobs (its names, its stage across capabilities, its files, its owner) joined to module state per row; nobody builds a browsed list on GET /jobs (vb-fable's shape check, 2026-08-18) |
GET /jobs/{job_id} | required | jobs:read | state + counts + envelope summary |
GET /jobs/{job_id}/envelope | standard | jobs:read | the job envelope as stored, verbatim, minus items; named envelope so manifest means only the module's capability manifest (versable-runner serves this at /jobs/{job_id}/manifest, a lapse) |
GET /jobs/{job_id}/outcomes | required | jobs:read | paginated outcomes; filters type=result, error, needs_review, or skipped, reason, error_type, item_id (repeatable) and item_id_prefix, so a review queue pages by reason and a person finds one item without loading the job; only the page is loaded |
GET /jobs/{job_id}/outcomes/{item_id} | required | jobs:read | one item's outcome |
GET /jobs/{job_id}/outcomes/{item_id}/log | required | jobs:read | the per-item log: every attempt's structured lines, paginated (canon/07) |
GET /usage/events | standard | stats:read | paginated usage events for the tenant, filters job_id, from, to, meter (contracts/usage-event.md) |
GET /jobs/{job_id}/errors | standard | jobs:read | the error outcomes, paginated; convenience over outcomes?type=error |
GET /jobs/{job_id}/review | standard | jobs:read | the needs_review outcomes, paginated; ?group_by=reason returns per-reason counts with a sample outcome each (canon/10) |
GET /jobs/{job_id}/outputs | standard | jobs:read | the transforms this capability offers over the job's outcomes and any materializations, with high-water marks (canon/15) |
GET /jobs/{job_id}/outputs/{name} | standard | jobs:read | compute or serve one transform: paged rows, or a signed artifact URL; ?materialize=true, ?format= per manifest; carries computed_at, settled, pending |
GET /jobs/{job_id}/stats | standard | stats:read | timing, throughput, ETA while running, error breakdown by code with samples, review breakdown by reason, usage rollup, cost |
POST /jobs/{job_id}/cancel | required | jobs:cancel | request cancel; 202; idempotent |
GET /stats | standard | stats:read | module-wide for the tenant: jobs by state, items, per-capability breakdown, running jobs; created_after/_before; ?usage=true for cost rollup |
POST /internal/tasks/{unit} | required if queued | platform identity only | the queue's callback routes; never reachable with a caller credential (canon/02) |
Differences from versable-runner today: /results becomes /outcomes with a
type filter and a per-item route; /review is new; /manifest and
/build-info are new; /jobs/run-file (multi-step run files) is a
capability of the content module, not a contract route; meta becomes
attribution; every job route gains the tenant.
Shapes#
Create a job#
POST /jobsAuthorization: Bearer …X-Tenant-Id: t_speedwayContent-Type: application/json{ "capability": "content.generate", "client_job_id": "wr_456/stage-7", optional; idempotent create per caller "params": { … validated against manifest.capabilities[capability].params … }, "items": [ { "item_id": "sku-1001", "input": { … } }, item_id optional; positional if absent … ], "parent_job_id": "job_…", "items_from_parent": ["sku-1001", "sku-1003"], optional: a child job over a subset of a parent's items (resolution, re-run, debug); items may be omitted when items_from_parent is set "attribution": { "user": { "id": "u_123", "label": "A. Chopra" }, "workflow_run": "wr_456" }, optional, claimed; `user` has the reserved shape {id, label} "variants": { "research": "self" }, optional, one option per declared dimension; absent means each dimension's default "settings": { optional, manifest says which are allowed "max_attempts": 3, "item_timeout_s": 900, "callback_url": "https://app.example/hooks/foundry", "callback_events": ["job.state", "item.outcome"] }}202{ "job_id": "job_01H…", "state": "accepted", "client_job_id": "wr_456/stage-7", "created_at": "…" }- Unknown capability, invalid params, or invalid items fail fast with 422
and a
detailsarray naming each problem. Nothing is persisted. - A repeated
client_job_idfrom the same caller returns200with the existing job. - Item count above the manifest's
max_itemsis 422; a per-caller or per-tenant ceiling is 429 withRetry-After.
Job status#
GET /jobs/{job_id}200{ "job_id": "…", "client_job_id": "…", "capability": "…", "state": "running", "counts": { "total": 5000, "pending": 3210, "running": 20, "succeeded": 1700, "errored": 40, "needs_review": 30, "cancelled": 0, "skipped": 0 }, "tenant": "t_speedway", "caller_id": "app_console_prod", "env": "prod", "attribution": { … }, "parent_job_id": null, "created_at": "…", "started_at": "…", "updated_at": "…", "finished_at": null}Outcomes#
GET /jobs/{job_id}/outcomes?type=needs_review&limit=100&offset=0200{ "outcomes": [ { "item_id": "sku-1001", "type": "result", "output": { … }, "confidence": 0.93, "judged_by": "self", "evidence": { … }, "variant": { "research": "extractor" }, which declared option ran "reference_versions": [ { "name": "pcdb", "version": "2026-07" } ], "attempts": 1, "duration_ms": 8120, "usage_ref": ["u_…", "u_…"], "finished_at": "…" }, { "item_id": "sku-1002", "type": "error", "error_type": "RESEARCH_QUALITY_LOW", "message": "…", "retryable": false, "attempts": 3, "duration_ms": 25400, "usage_ref": ["u_…", "u_…", "u_…"], "finished_at": "…" }, { "item_id": "sku-1003", "type": "needs_review", "reason": "AMBIGUOUS_PART_TYPE", "confidence": 0.41, "judged_by": "judge.parttype", "evidence": { … }, "output": { … partial … }, "attempts": 1, … }, { "item_id": "sku-1004", "type": "skipped", "reason": "cancelled", … } ], "count": 30, "total": 30, "offset": 0, "limit": 100, "next_offset": null}error_type and reason are stable codes listed in the manifest, so an app
can build UI and rules against them without parsing messages.
variant and reference_versions are conditional, not part of every
outcome. A capability that declares variants in its manifest carries
variant on each outcome, naming the option that actually ran for that item,
because a job may fall back per item (on_missing: fallback:<dimension>:<option>)
and a reader comparing two outcomes otherwise cannot tell which path produced
which. A capability that declares reference_data carries
reference_versions, the snapshot it loaded, so an outcome stays explainable
after the reference data moves. A capability declaring neither omits both
fields entirely; declaring one and omitting its field is a conformance
failure, not an optional extra.
Callback events#
POST {callback_url}X-Foundry-Signature: sha256=… HMAC over the body with the caller's callback secretX-Foundry-Event-Id: evt_… idempotency key for the receiverContent-Type: application/json{ "event": "job.state", "job_id": "…", "client_job_id": "…", "tenant": "…", "state": "completed_with_review", "counts": { … }, "occurred_at": "…" }{ "event": "item.outcome", "job_id": "…", "client_job_id": "…", "tenant": "…", "outcomes": [ … up to N … ], "occurred_at": "…" }At-least-once, retried with backoff, dead-lettered with a count when it
cannot be delivered. canon/03, "Learning that something changed".
Errors#
422{ "error": { "code": "VALIDATION", "message": "2 problems", "details": [ { "path": "params.template", "message": "required" }, { "path": "items[3].input.brand", "message": "must be a string" } ] } }429Retry-After: 30{ "error": { "code": "CEILING", "message": "caller ceiling of 2 concurrent jobs reached" } }Conformance checklist#
A module can score itself against this list; contracts/conformance.md will
carry the smoke suite that exercises it.
- every route above marked required exists and behaves as described
- every job route rejects a missing or wrong tenant, and a credential from another environment
-
POST /jobswith 5,000 items returns in under a second and the job isacceptedorexpandingimmediately after - a repeated
client_job_idreturns the existing job - every list is paginated and states its max page size in the manifest
- every outcome carries
item_id,type,attempts,duration_ms,usage_ref; everyresultandneeds_reviewcarriesconfidenceandjudged_by -
error_typeandreasonvalues are all listed in the manifest - cancel on a running job ends in
cancelledwith in-flight items settled and unstarted itemsskipped - a callback is delivered at least once and carries a valid signature
-
/health/deepfails when storage or queue is unreachable -
/build-inforeports the running commit without a credential - the internal routes reject a caller credential
- the module starts with zero env in the in-process mode and refuses to
start with the
localverifier on a public URL
Banned#
- A shared credential across callers on any route.
- A job route that works without a tenant.
- An unpaginated list.
- A
runningstate before expansion. - A
/resultsshape that requires downloading every outcome to read one. - A callback without a signature or an event id.
- Secret values in
/health/deep's config echo.