The first module is not greenfield: services-api runs today, does content
generation for real callers, and is the most complete instance in the tree.
Bringing it onto the contract is the worked example every other module
follows, so this doc lays it out as a sequence with the parity ledger first,
the way versable-builder's migration guide insists on
(versable-builder/docs/app-patterns/04-migrating-an-app.md, "step zero is
not optional").
Audience: whoever does the upgrade, and anyone using it as the template for the next migration.
Step zero: the parity ledger#
What services-api does today that must still be true afterwards, from
../instances/versable-runner.md and src/services-api/docs/runner-service.md.
Every row is checked before the old surface is removed.
| Behaviour today | Keep | Check |
|---|---|---|
POST /jobs with {data, method, params, meta} returns a job id in O(1) | yes, under the new envelope; old body shape accepted through a compatibility adapter for one release | submit 5,000 items, time it |
POST /jobs/run-file multi-step run files | yes, as a capability (content.run_file) rather than a contract route | run an existing run-file |
enqueuing state visible right after submit | yes, renamed expanding; both strings served for one release | poll immediately after submit |
| per-item retry, 3 attempts, handler-owned; queue backstop at 5 | yes | fault-inject a retryable error |
RESEARCH_QUALITY_LOW non-retryable | becomes needs_review with reason UNVERIFIABLE_CLAIM; the old error type is listed in the manifest as deprecated for one release | run an unverifiable item |
GET /jobs/{job_id}/results?offset&limit | served, plus /outcomes with type filter; /results removed after callers move | page a large job |
GET /jobs/{job_id}/stats, /errors, /stats?usage=true, cost by model | yes, unchanged shapes plus tenant | compare a stats response before and after |
meta tags, X-Meta-* headers, meta.<key> filters | become attribution; old names accepted and mapped for one release | filter by an existing tag |
GET /usage self-documenting guide | yes, generated from the manifest, but the route becomes /guide because /usage/events is the usage route; keep /usage as an alias for one release | diff old and new text, then confirm both paths answer before dropping /usage |
/health, /health/deep, /docs | yes | curl |
/health response shape | changes | live returns {"ok":true}; the contract specifies {status:"ok"} (contracts/module-surface.md). Align to the contract, and keep ok:true alongside it for one release so existing probes do not break. Verify with curl -s <url>/health and confirm both keys present, then drop ok in the release after |
| GCS-backed enhancement cache keyed by pipeline input | yes, behind ctx.cached | cache hit on a repeated item |
| Cloud Tasks OIDC on internal routes | yes | reject a caller credential on /internal/* |
RUNNER_DISPATCH=local in-process mode | yes, as env: local with all adapters local | run with zero env |
deploy via deploy.sh, secrets by reference | yes, plus /build-info stamping | deploy dev |
| dev and prod services, buckets, queues | yes, plus separate service accounts | dev cannot read prod's bucket |
The sequence#
Ordered so every step leaves a running service and can ship on its own.
- Tenant and caller context, additive. Add the verifier port with the
existing shared password as a
keystable of one row (canon/02, option a's degenerate case), and start requiringX-Tenant-Idon job routes with a grace period where a missing tenant is logged, not rejected. Stamp tenant, caller, env on every new job, outcome, and usage record. Old records havetenant: nulland the stats routes say so. - Separate identities per environment. New service account for
runner-service-dev; new bucket and queue permissions;deploy.shparameterized on it. No code change; a provisioning change. /build-infoand/manifest. Stamp commit and version at deploy; generate the manifest from the method registry the way/usagealready is, withparams,item,outputschemas written forcontent.generateandcontent.run_file, error types and review reasons enumerated from the code, ceilings from the queue config, meters from whatusage.pyalready records.- Outcomes. Add
/outcomeswith the type filter and per-item route over the existingresults/,errors/,usage/blobs; addneeds_reviewas a fourth blob prefix; routeRESEARCH_QUALITY_LOWto it./resultskeeps serving. client_job_idand idempotent create. A small jobs index (one row per job: id, tenant, caller, client_job_id, capability, state, created, attribution) that the list route reads instead of listing bucket prefixes; per-item truth stays in the blobs (canon/05, "The state-table decision").- Per-item log.
ctx.logwriting chunked lines under…/log/{item_id}/;/outcomes/{item_id}/logroute;log_levelin settings. - Callbacks.
callback_urlandcallback_eventsin settings; signed POSTs on job state change; at-least-once with a dead-letter prefix; the sweep is the caller's. - Runner verbs. Move the payload's direct uses behind
Context: Langfuseobservebecomesctx.trace; the GCS cache becomesctx.cached; usage sidecar writes becomectx.meter(../contracts/runner-verbs.md). Thelib/code stops importinglangfuse(lib/providers/gemini/__init__.py:11), and the cache backend wiring inapp/main.py(set_backend_factory(…, GCSKVBackend)) becomes thecachedadapter the runner owns. - Retention. Lifecycle rules on both buckets per
canon/05defaults; usage blobs excluded. - Verifier to
jwksonce the issuer exists (../adr/002); the request shape does not change, so callers move at their own pace by swapping the credential they send. - Remove the compatibility adapters (
meta,/results,enqueuing) one release after every caller has moved, announced in the manifest'sdeprecations.
What not to touch#
- The fanout and per-item task design, the retry budget,
if_generation_match=0, theusage/sidecar, the stats math. These are the parts the contract adopted from this service. lib/payload code beyond the import changes in step 8. The upgrade is the runner's; the domain does not move.
Done when#
- The conformance table passes in-process and against dev.
- The parity ledger's Check column is all green.
../instances/versable-runner.mdis rewritten asinstances/content.mdwith an empty-or-honest Lapses section.- A second caller (the console in local mode is enough) submits, reads outcomes, receives a callback, and reads a per-item log without shell access.