Agent docs

Upgrading services-api onto the contract

The first module is not greenfield: services-api runs today, does content generation for real…

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 todayKeepCheck
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 releasesubmit 5,000 items, time it
POST /jobs/run-file multi-step run filesyes, as a capability (content.run_file) rather than a contract routerun an existing run-file
enqueuing state visible right after submityes, renamed expanding; both strings served for one releasepoll immediately after submit
per-item retry, 3 attempts, handler-owned; queue backstop at 5yesfault-inject a retryable error
RESEARCH_QUALITY_LOW non-retryablebecomes needs_review with reason UNVERIFIABLE_CLAIM; the old error type is listed in the manifest as deprecated for one releaserun an unverifiable item
GET /jobs/{job_id}/results?offset&limitserved, plus /outcomes with type filter; /results removed after callers movepage a large job
GET /jobs/{job_id}/stats, /errors, /stats?usage=true, cost by modelyes, unchanged shapes plus tenantcompare a stats response before and after
meta tags, X-Meta-* headers, meta.<key> filtersbecome attribution; old names accepted and mapped for one releasefilter by an existing tag
GET /usage self-documenting guideyes, generated from the manifest, but the route becomes /guide because /usage/events is the usage route; keep /usage as an alias for one releasediff old and new text, then confirm both paths answer before dropping /usage
/health, /health/deep, /docsyescurl
/health response shapechangeslive 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 inputyes, behind ctx.cachedcache hit on a repeated item
Cloud Tasks OIDC on internal routesyesreject a caller credential on /internal/*
RUNNER_DISPATCH=local in-process modeyes, as env: local with all adapters localrun with zero env
deploy via deploy.sh, secrets by referenceyes, plus /build-info stampingdeploy dev
dev and prod services, buckets, queuesyes, plus separate service accountsdev cannot read prod's bucket

The sequence#

Ordered so every step leaves a running service and can ship on its own.

  1. Tenant and caller context, additive. Add the verifier port with the existing shared password as a keys table of one row (canon/02, option a's degenerate case), and start requiring X-Tenant-Id on 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 have tenant: null and the stats routes say so.
  2. Separate identities per environment. New service account for runner-service-dev; new bucket and queue permissions; deploy.sh parameterized on it. No code change; a provisioning change.
  3. /build-info and /manifest. Stamp commit and version at deploy; generate the manifest from the method registry the way /usage already is, with params, item, output schemas written for content.generate and content.run_file, error types and review reasons enumerated from the code, ceilings from the queue config, meters from what usage.py already records.
  4. Outcomes. Add /outcomes with the type filter and per-item route over the existing results/, errors/, usage/ blobs; add needs_review as a fourth blob prefix; route RESEARCH_QUALITY_LOW to it. /results keeps serving.
  5. client_job_id and 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").
  6. Per-item log. ctx.log writing chunked lines under …/log/{item_id}/; /outcomes/{item_id}/log route; log_level in settings.
  7. Callbacks. callback_url and callback_events in settings; signed POSTs on job state change; at-least-once with a dead-letter prefix; the sweep is the caller's.
  8. Runner verbs. Move the payload's direct uses behind Context: Langfuse observe becomes ctx.trace; the GCS cache becomes ctx.cached; usage sidecar writes become ctx.meter (../contracts/runner-verbs.md). The lib/ code stops importing langfuse (lib/providers/gemini/__init__.py:11), and the cache backend wiring in app/main.py (set_backend_factory(…, GCSKVBackend)) becomes the cached adapter the runner owns.
  9. Retention. Lifecycle rules on both buckets per canon/05 defaults; usage blobs excluded.
  10. Verifier to jwks once the issuer exists (../adr/002); the request shape does not change, so callers move at their own pace by swapping the credential they send.
  11. Remove the compatibility adapters (meta, /results, enqueuing) one release after every caller has moved, announced in the manifest's deprecations.

What not to touch#

  • The fanout and per-item task design, the retry budget, if_generation_match=0, the usage/ 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.md is rewritten as instances/content.md with 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.
@versable-git/ui · reference, canon, and method, read in place