Agent docs

Guide: service granularity

Few big services with capabilities added inside, or many small modules?

Few big services with capabilities added inside, or many small modules? And composability inside one system, or across a contract? The estate has taken this decision three ways and lived with each, so this guide can say what each way bought and cost rather than argue from principle. It ends with the tells that say which way fits a given case, not with a winner; adr/001 records which way the module tree takes and why, and this guide is the reasoning behind that being a choice rather than a law.

Audience: anyone about to add a capability and deciding where it goes.

The three ways, as lived#

App V5: as few services as possible, add capabilities inside#

The owner's description, 2026-08-18: "we deploy as few services as possible, Render + Vercel, and add capabilities to existing services. Pipeline composability existed but it was all in-system, this caused issues when integrating the external extractor. It did have a lot of benefits like not having to reimplement a lot of the same functionality, but then what we had was what all we had, and upgrades to the system needed architectural headroom."

What it bought: one runner, one auth, one credits system, one observability stack, one deploy; a new capability was a method in the registry and a pipeline step, and every capability got retries, breakers, heartbeats, credits, and Sentry for free (../instances/enhancement-product.md). For the first years and the first customers this was the fastest possible way to add capability.

What it cost: the composability was the pipeline's, so anything not shaped like a pipeline step (the external extractor with its own job lifecycle; M rows from N items) fought the system instead of plugging in. Upgrades needed headroom the monolith did not have: a fitment agent's memory profile became every worker's ceiling (worker OOM 2026-05-29), a cron's materialization became the whole notify path's OOM (2026-05-28), and one shared X-Api-Token was every machine caller's credential. And when customers needed a different data model, the answer was a new app with the capabilities copied (00-charter.md, "The problem").

The MVPs: an app per customer shape, capabilities copied in#

speedway and walmart-mvp each carry their own runner and their own copies of part-type matching, scraping, normalization, and content generation, tuned to their data model. What it bought: shipping speed for a customer whose workflow did not fit App V5, and freedom to choose Firestore or Postgres per app. What it cost: the ninety-percent duplication the owner named, QA and edge cases and credits and logs done again per app, and two runners with gaps the original did not have (walmart lost heartbeat and cancel in its rewrite; speedway copy-pasted its checkpoint loop three times).

The runner forks: one harness, cloned per capability set#

runner-service and services-api share a runner byte-for-byte in most of app/ and diverge only by features one picked up. What it bought: a proven runner reused with almost no cost, and a capability set that could be scoped per deployment. What it cost: the clones drift silently (the four-file diff), and nothing enforces that a fix lands in both. It is the accidental version of the module tree.

What the module tree chooses, and why it is a choice#

adr/001: one runner implementation, many module deployments, each with a manifest, its own version and identity audience, callable standalone and composable through requires/provides. It takes App V5's "do not reimplement" benefit by making the runner a template and a library instead of a deployment, and it takes the MVPs' "different shape per app" benefit by putting the shape in the app and the capability in a module. It pays for that with N deployments to provision and a shared identity service.

That trade is right when: capabilities are called from more than one app; capabilities need different scaling or memory profiles; two versions of one capability must run at once; a capability has an outside lifecycle (a vendor job) that a pipeline step cannot model. It is wrong, or at least premature, when: one app calls one capability and no second caller is coming; the team is one person shipping this week; the "capability" is a data transform with no vendor, no model, and no state, which is a function, not a module.

The tells#

Signs the current granularity is too coarse (the App V5 failure):

  • a new capability needs a change to shared runner code to fit
  • one capability's resource profile sets everyone's ceiling
  • an external system with its own job lifecycle has to be wrapped as if it were a step
  • "what we had was what all we had": a new shape (M≠N, a review step, a second environment) means an architectural change, not a config change
  • one credential, one deploy, one release for everything

Signs it is too fine (the MVP failure in a different costume):

  • the same capability exists in three places with three sets of tests
  • a fix has to be applied N times
  • each service has its own answer to auth, logging, credits, retries
  • provisioning a new module takes longer than writing its payload

Signs it is right:

  • adding a capability is a payload plus a manifest entry, and it gets retries, cancel, metering, logs, and identity without writing them
  • a module can be deployed, versioned, and scaled without touching another
  • an app can swap one module for another version by changing what it calls
  • an outside system plugs in as a module with the same surface as an inside one (../instances/README.md, the extractor as an instance)

The headroom rule#

App V5's lesson stated as one sentence: build the seams before you need them, because adding a seam to a running system costs more than the seam ever would have. The contract's seams (runner/payload, module/app, outcomes/outputs, worker run/transform, verifier port, queue port, storage port) are each one of those, and each one is there because an instance needed it after the fact.

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