The normative shape of GET /manifest and the shared vocabulary that
requires and provides name across modules. canon/11 says why; this says
exactly what. Written 2026-08-17 at the session's default effort (high) as a first cut;
the field registry in particular wants a deeper pass before the first two
modules build against it (../PLAN.md, nudges).
Audience: anyone publishing a manifest, generating UI from one, or writing the composer that will read them.
Top level#
| Field | Type | Required | Meaning |
|---|---|---|---|
module | string matching ^[a-z][a-z0-9-]*$ (anchored; the whole value) | yes | stable module id, never reused for a different thing |
module_version | semver string | yes | what this deployment promises; bumped per canon/11 versioning |
contract_version | string | yes | which Foundry contract this conforms to; 0.1 for this tree as written |
build | {commit: string, built_at: RFC 3339 string, branch?: string} | yes | same values as /build-info, whose name and version are this manifest's module and module_version |
env | local, dev, staging, prod | yes | |
capabilities | map of capability id to capability object | yes, at least one | |
surface | {routes: array of strings, page_size: {default: integer >= 1, max: integer >= 1}, callbacks?: {events: array of strings, signature: string, max_batch: integer >= 1}} | yes | routes present as <METHOD> <path> exactly as module-surface.md writes them (internal routes deliberately unlisted), page sizes, callback support |
identity | {verifier: one of jwks, keys, remote, local; issuer: string, audience: string, scopes: array of strings} | yes | verifier in use (../guides/03-machine-identity.md), issuer URL, audience, scopes this module recognises |
runtime | {queue: string, storage: one of gcs, s3, local; in_process_supported: bool, topology: one of self-push, api-worker} | yes | queue adapter, storage adapter, whether in-process is supported, dispatch topology (canon/04) |
retention | map of one of inputs, outcomes, artifacts, logs, usage, caches to {dev: duration, prod: duration} | yes | canon/05 classes: inputs, outcomes, artifacts, logs, usage, caches. A jobs index or any other structure rebuildable from the outcome records is not a class and declares no retention |
vendors | array of {key: string matching ^[A-Z][A-Z0-9_]*$, purpose: string, required: bool} | no | outbound credentials this module needs, by env-var name (canon/09 vendors.*). Declared so an operator can see what to supply per module without reading its source; /health/deep reports each as set or absent and never the value. Absent means the module calls no vendor |
deprecations | array of {kind: one of capability, route, error_type, review_reason, state; target: string, deprecated_at: RFC 3339 string, replaced_by: string, removal_after: RFC 3339 string} | no | announced before removal; target is the id, route or code being retired (canon/14, ../patterns/03) |
states | array of one of paused | no | optional job states this module implements beyond the standard set (canon/03); absent means none |
A capability#
Capability ids are <domain>.<verb>[.<qualifier>], lowercase, dotted:
content.generate, parttype.match, attribute.normalize,
image.generate, extract.pages, judge.copy. A judge is a capability.
Batching is not a qualifier: a capability that prefers batches declares a
run_batch entry point (runner-verbs.md) under the same id.
| Field | Type | Required | Meaning |
|---|---|---|---|
summary | string | yes | one line for a person or an agent |
params | JSON Schema (draft 2020-12) | yes | job-level parameters; validated on create; the UI for the capability's settings is generated from this |
item | JSON Schema | yes | shape of items[].input; a Catalog Output Template targets this |
output | JSON Schema | yes | shape of a result outcome's output |
error_types | map of code to {label: string, description: string} | yes | closed set (the reserved code DEFERRED_TOO_LONG is declared by every capability that uses the deferred disposition, canon/03); every error outcome's error_type is one of these; label is the short human text a status pill shows, description the one-line tooltip, so an app never invents strings for module codes |
review_reasons | map of code to {label: string, description: string} | no at 0.1; yes from 0.2 | closed set for needs_review.reason, same label and description rule; INPUT_MISSING and JUDGE_UNAVAILABLE are reserved codes every capability with a degradable requirement lists (see Requirements). skipped outcomes carry a reason from the CONTRACT-level closed set duplicate, filtered, cancelled, superseded, not from this map |
confidence | {self: bool, judges: array of capability ids, authoritative: string} | no at 0.1; yes from 0.2 | authoritative is self (legal only when self is true) or a member of judges (conformance checks the membership); whether the payload self-reports, which judges it can chain, which score fills confidence |
requires | array of requirement objects | no | inputs beyond item the capability needs or can use (below) |
provides | array of field ids | no | what this capability writes onto an item, from the field registry |
variants | map of variant dimension to {options: array of strings, at least one; default: string}, default one of options (conformance checks the membership) | no | canon/14; research: {options: [self, extractor], default: self} |
ceilings | {max_items: integer >= 1, max_concurrent_jobs_per_caller: integer >= 1, max_concurrent_jobs_per_tenant: integer >= 1, max_item_bytes: integer >= 1, max_deferrals?: integer >= 1} | no at 0.1; yes from 0.2 | what 429 and 422 mean here; max_deferrals only when the module uses the deferred disposition (canon/03) |
settings | array of strings | no at 0.1; yes from 0.2 | which per-job overrides this capability honours, as bare key names (max_attempts, never settings.max_attempts) |
cost_model | {unit: string, typical_usd: number >= 0, meters: array of strings} | no at 0.1; yes from 0.2 | what usage events to expect and a typical cost per unit; meters lists only the capability's OWN meters (<domain>.<name>); the standard meters of usage-event.md (item, duration, plus cache.hit when cache is present and review.held when review_reasons is non-empty) are implied and are never listed here |
cache | {keyed_by: array of strings (item field paths), ttl: duration, bypassable: bool, tenant_scoped: bool} | no | canon/06; absent means not cached; keyed_by names the item fields ADDED to an implicit key of capability id, module_version, the chosen variant and every reference_data version; tenant_scoped: false is the explicit declaration that a cache is shared across tenants and is only legal for tenant-free inputs |
reference_data | array of {name: string, version_reported_at: "/health/deep"} | no | snapshots this capability loads |
log | {levels: array of strings, at least one; default: string}, default one of levels; info is the envelope default (canon/07) and every module lists it | no at 0.1; yes from 0.2 | supported log_level values |
facets | array of strings (attribution keys) | no | a facetable key is low-cardinality by contract: a facet returns at most 50 values plus an other bucket; GET /jobs is module-level, so its facet set is the union across capabilities and ?capability= narrows it; which attribution.<key> values GET /jobs returns live counts for; user is always facetable and has the reserved {id, label} shape |
outputs | map of output name to {shape: JSON Schema or file type string, version: string, reads: array of strings (outcome field paths), partial: bool, formats: array of one of csv, xlsx, jsonl} (canon/15) | no | declared transforms over this capability's outcomes (canon/15); absent means only raw outcomes. A reads entry ending in .* means every key present under that prefix, resolved per job; it is the only non-literal form and exists for capabilities whose columns a person names at run time |
Requirements#
A requirement names an input the capability wants beyond the raw item, and
where it may come from. It is a table so the generator builds it the way it
builds the two above; the hand-written example that used to sit here is now
canon/11.
| Field | Type | Required | Meaning |
|---|---|---|---|
field | registry id | yes | a field-registry id; the schema checks it against the registry enum |
need | one of required, optional | yes | required: missing means needs_review or 422, and on_missing says which |
from | array of strings, at least one | yes | who may supply it: caller (in the item input), <module>/<capability> (a named capability of a named module), or a bare <capability> meaning a capability of THIS module |
on_missing | string | yes | one of needs_review:<CODE> (the code MUST appear in this capability's review_reasons; INPUT_MISSING is the reserved code for the plain case, JUDGE_UNAVAILABLE for a missing judge), fallback:<dimension>:<option> (a declared variant), or reject (a 422 at create time, canon/03) |
Where a field lives. A registry id IS its path. part.type is read at
items[].input.part.type on the way in and written at output.part.type
on the way out, dotted id as nested path, so a composer holding two
manifests can move a value from one module's outcome into the next module's
item without opening either schema. A capability whose output schema puts
a provided field anywhere else is not conforming (Banned, below).
This is the whole composability hook. A standalone caller supplies the
field in the item input and never reads from. The console (or the future
composer) reads from to know that a parttype.match job before this one
can fill it. The field id names the value; the path rule places it.
The field registry#
A shared vocabulary of item field ids, so provides in one module and
requires in another, and the console's own Field concept, name the same
thing. Registry entries are <domain>.<name>, lowercase, dotted, with a
type and a one-line meaning. The registry lives in this file until it
outgrows it.
First cut, from what the three instances and the owner's draft actually pass around. Marked seed because the first two modules will change it:
| Field id | Type | Meaning | Seen in |
|---|---|---|---|
part.number | string | the part number as the vendor writes it | every instance |
part.number_normalized | string | normalized part number for matching and joins | speedway normalizedSku, walmart part-number join |
part.brand | string | brand name as given | every instance |
part.type | string | part-type id or name in the tenant's taxonomy | speedway $derived.partType, walmart stage 2 |
part.type_confidence | number | 0 to 1, the matcher's confidence in part.type for this item; distinct from the outcome's confidence, which is the capability's overall score | speedway, walmart |
attributes.raw | map | attribute name to value as sourced | speedway scrape, walmart scrape |
attributes.normalized | map | attribute name to accepted value in the tenant's schema | speedway normalize, walmart stage 5 |
attributes.evidence | map | attribute to source quote and URL | speedway scrape evidence |
attributes.destination | map | attribute name to value in the DESTINATION schema (spec-mapped for publish), as opposed to attributes.normalized in the tenant's schema | speedway spec mapping, walmart stage 6 |
content.title | string | the product title as generated or normalized | content module, walmart stage 7 |
part.gtin | string | GTIN, UPC or EAN as the vendor writes it | speedway, walmart |
part.category | string | category id in the DESTINATION taxonomy (the marketplace or catalog being published to), as opposed to part.type in the tenant's own taxonomy | walmart stage 3, speedway publish |
content.description | string | the long description as generated or normalized | content module, walmart stage 7 |
content.bullets | array of string | feature bullets as generated | content module, walmart stage 7 |
content.research | object | research findings used for copy | content module research mode |
images.source | array of URL | source image URLs as supplied | walmart images |
images.rendered | array of artifact refs | rendered images the module produced | image generation, versable-runner object-store output |
fitment.aces | array of object | ACES fitment applications as parsed, one object per application | speedway ingest |
pies.attributes | object | PIES attributes as parsed | speedway ingest |
source.file | map of field id to ref | which upload each value came from, per field | speedway per-field provenance |
Each registry entry will also carry a presentation hint (type family, unit,
tone) mapping onto kit cell types (text, number with unit, status tone,
timestamp, link, chip list), so an app can render fields with the kit's
column factories without a module ever describing layout (vb-fable,
2026-08-18; ../BACKLOG.md).
Rules for the registry:
- A capability's
providesandrequiresuse registry ids only. A new field is a registry change, reviewed like a schema change. - The registry does not fix the JSON Schema of every field's value; it fixes
the id, the type family, and the meaning. Capabilities that write
attributes.normalizedall mean the same thing by it, and theiroutputschemas say the exact shape. - Tenant-specific vocabularies (a customer's taxonomy ids, their attribute
names) are values, not registry ids.
part.typeis the field; the tenant's taxonomy is what fills it. - Growth: adding a registry id is a
contract_versionminor bump; a validator that meets an id it does not know WARNS and does not reject, so an older console keeps working against a newer module. Renaming or retyping an id is a major bump. - The
x.prefix is reserved for ids that are deliberately not shared (one module's private field); anx.id may appear inprovidesand never inrequires. review.reasonis not a registry field: why an item was held is the outcome'sneeds_review.reason, an app mirrors it, and no capability provides or requires it.
Surface, identity, runtime#
"surface": { "routes": ["POST /jobs", "GET /jobs", "GET /jobs/{job_id}", "GET /jobs/{job_id}/envelope", "GET /jobs/{job_id}/outcomes", "GET /jobs/{job_id}/outcomes/{item_id}", "GET /jobs/{job_id}/outcomes/{item_id}/log", "GET /jobs/{job_id}/errors", "GET /jobs/{job_id}/review", "GET /jobs/{job_id}/outputs", "GET /jobs/{job_id}/outputs/{name}", "GET /jobs/{job_id}/stats", "POST /jobs/{job_id}/cancel", "GET /stats", "GET /usage/events", "GET /health", "GET /health/deep", "GET /build-info", "GET /manifest", "GET /guide"], "page_size": { "default": 100, "max": 1000 }, "callbacks": { "events": ["job.state", "item.outcome", "usage"], "signature": "hmac-sha256", "max_batch": 500 } }"identity": { "verifier": "jwks" | "keys" | "remote" | "local", "issuer": "https://id.versable.ai", "audience": "content", "scopes": ["jobs:submit", "jobs:read", "jobs:cancel", "stats:read", "stats:admin"] }"runtime": { "queue": "cloudtasks" | "arq" | "in-process", "storage": "gcs" | "s3" | "local", "in_process_supported": true, "topology": "self-push" | "api-worker" }Validation#
A manifest is itself validated against contracts/manifest.schema.json,
generated from the field tables in this file by
scripts/build-manifest-schema.py (the runner template's step 1 produced
it, ../plans/runner-template.md; --check exits 2 when the file is stale).
The generator reads only the Field, Type and Required columns and the
registry ids; a Meaning cell is prose and binds people, not the schema.
Type cells therefore use a small grammar: string, integer, number,
bool, RFC 3339 string, array of <type>, map of <key> to <type>,
one of a, b, c for a closed set (never a bar, which breaks the table), >= n for a bound, at least one for a
non-empty array, {name: type, other?: type} for members, map of one of a, b to <type> for a map whose keys are pinned, the named type
registry id (the enum of field ids in this file's registry table), and the named type
duration, a string matching ^([0-9]+[hdy]|never|ttl)$ (7d, 90d, 1y,
never; ttl only for the caches class), the spelling canon/11 uses. Membership
constraints written in prose (default one of options, default one of
levels) are conformance checks, not schema. params, item and output
are asserted to be objects and no more; the kit's lintSchema below owns
their vocabulary. contracts/conformance.md checks
that a live module's behaviour matches its manifest: every listed route
exists, every emitted code is listed, every honoured setting is listed,
ceilings are enforced.
The params, item and output schemas are full JSON Schema draft 2020-12
(owner ruling 2026-08-18: "the contract stays wide"). The kit's form
vocabulary is GUIDANCE for a module that wants its params rendered by
SchemaForm without a hand-built form, not a gate on the manifest: the
kit's lintSchema(schema) (@versable-git/ui,
packages/ui/src/schema-form/lint-schema.ts, contract doc
packages/ui/docs/schema-form.md, subset per versable-builder
docs/plan/65-schema-form-spec.md section 6) refuses by name, at a JSON
Pointer, anything outside that subset and returns {ok, plan} or
{ok, refusals[]}; a module runs it when it is ready to ship a generated
form, and a params schema outside the subset is rendered by a hand-built
form or the JSON view, never rejected by the runner. The four V1 modules'
params schemas start from the fixtures at versable-builder
docs/plan/65-schema-form-fixtures/{enhancement,image-generation,attribute-normalization,part-type-matching}.params.schema.json,
which are the vocabulary's worked examples; a manifest that diverges from
its fixture is either a lapse or a fixture correction, and the instance doc
says which. contracts/conformance.md MAY run the lint over a published
manifest as an advisory row (foundry-2), never a failing one.
Two rules that hold whatever vocabulary a params schema uses, because the
runner validates the submitted params against the schema on create
(canon/03) and knows nothing about form-only keys: a field hidden by a
form-only condition (x-when, x-tier) MUST NOT be required at the
schema level unless it sits inside a discriminated oneOf branch that JSON
Schema itself selects, since a hidden required field is a guaranteed 422;
and x-when may reference $settings.<key> only for keys the capability
lists in settings, because that object is the job's settings block, not
app-private context.
Standard settings#
The per-job overrides every module names the same way, so an app carries no
translation table. A capability lists the ones it honours in settings; it
may add settings under its own <domain>. prefix and may not rename these.
| Setting | Type | Meaning |
|---|---|---|
max_attempts | integer >= 1 | retry budget per item (canon/03) |
item_timeout_s | integer >= 1 | per-item wall clock before the attempt errors |
callback_url | string | where callback_events are delivered (canon/04) |
callback_events | array of strings | which of the module's callback events to send |
log_level | string | one of the capability's log.levels (canon/07) |
cache | one of use, bypass | bypass skips the result cache and is metered as a miss (canon/06) |
Banned#
- A capability id that is not
<domain>.<verb>[.<qualifier>]. - A
providesorrequiresentry that is not a registry id. - A provided field whose value sits anywhere but at its id's path in
output, or a required field read from anywhere but its id's path initems[].input. - An
x.registry id inrequires. - A
meterslist that repeats a standard meter. - A
skippedoutcome whose reason is outsideduplicate,filtered,cancelled,superseded. - An
error_typeorreview_reasonemitted at runtime and absent here. - A
settingskey honoured at runtime and absent here. - A manifest at 0.2 or later that omits
ceilings,cost_model, orretention; at 0.1 a capability may omitreview_reasons,confidence,ceilings,settings,cost_modelandlogand its instance doc says which, so no invented number enters a machine-readable file (review v2 J4). - Two capabilities in one module with the same id and different schemas; that is a version bump or a new id.