Agent docs

Graceful degradation, variants, and several versions at once

> Evidence: 1 file:line witnesses as of 2026-08-18.

Evidence: 1 file:line witnesses as of 2026-08-18. Confidence: derived, written from the seams and the estate's failures rather than from citations; treat each rule as a design call and argue with it. What changes it: the first module built against this doc (../PLAN.md forge-1) and its instance breakdown.

The stated design goal, in the owner's words: "ease of swapping / upgrading / graceful degradation", and "multiple versions of the same capability running across the plethora of apps without two weeks of dev time to upgrade". This doc says what the contract does to make those cheap. No instance does this on purpose; the two runner forks run side by side today by accident, which is the closest the estate has come.

Audience: anyone shipping a second version of anything, or deciding how a module behaves when a dependency is missing.

Three things that have to be cheap#

  1. Swapping a capability's implementation (a new part-type matcher, a different research source) without touching callers.
  2. Upgrading a module while apps that depend on the old behaviour keep working, on their own schedule.
  3. Degrading when a dependency (a judge, an extractor, a vendor, the issuer) is slow, down, or absent, without failing the whole job or lying about the result.

Variants: different coverage for the same purpose#

A capability may offer more than one way to achieve the same output, and the job picks one. The manifest lists them under variants with a default (11-capability-manifest.md). The owner's two examples, 2026-08-17:

  • research: the content module does its own web research (self), or relies on an extractor step having run before and reads the evidence off the item (extractor). Same output schema, different cost, different dependency.
  • confidence: the payload rates its own output (self), or a judge scores it: a second model call inside the module, a separate judge module called after, or a judge the app runs and writes back. Same confidence field, different judged_by.

Rules for variants:

  • Every variant of a capability produces the same output schema and the same outcome types. A caller switching variants changes cost and quality, never the shape it reads.
  • The default variant is the one that runs standalone with no other module present. research: self is the default because a caller with no extractor must still get copy. Standalone always works.
  • A variant that depends on another capability's output declares it in requires and, if the input is missing on the item, either falls back to the default variant or emits needs_review with a stable reason, as the manifest states. It never silently produces a worse answer.
  • The chosen variant is recorded on every outcome (variant field) and on every usage event, so cost and quality can be compared per variant after the fact. That comparison is how the team learns which coverage to make the default.

Judges are capabilities#

A judge takes another capability's output as its item and returns a score with evidence. It is declared like any capability, with item = the judged capability's output schema. It can be deployed:

  • inside the module, as a second capability the runner chains after the first, per job settings
  • as its own module, called by the app after the first job settles, its results written back as a new job with parent_job_id, or attached by the future composer
  • in the app, where the app computes a score itself and records it in its own store; the module never sees it, and the app's bar is applied to the app's score

The contract only fixes the shape (confidence + judged_by + evidence) and the manifest's confidence.judges list. Where the judge runs is a deployment decision per capability, and the owner asked for all three to be possible.

Several versions of one capability#

Two ways, both allowed, and the manifest tells them apart:

  • Two deployments. content-v2 and content-v3 as separate module deployments, each with its own manifest, module_version, queue, and identity audience. Apps move when they choose. This is what the runner forks accidentally are, and it is fine on purpose. Cost: two of everything; the reason it is affordable is that a module is one runner template plus a payload, so "two of everything" is a config change and a deploy, not two codebases.
  • Two capabilities in one deployment. parttype.match and parttype.match.v2 side by side in one module's manifest, sharing runner, queue, and identity. Cheaper, one blast radius. Right when the two are small and the difference is the payload.
  • Unrelated capabilities co-hosted. The same mechanism carries capabilities that share nothing but the runner (V1 ships four this way, ../adr/001 Consequences). Allowed when the team is one, the callers are one, and no capability's memory or scaling profile diverges; the manifest lists every capability from day one so a later split is a config change. What stays per capability even while co-hosted, so the split is clean: ceilings, cost_model, cache, reference_data, error_types, review_reasons; what is shared and must be split with care: identity, runtime, retention, the queue, and the caller keys.

When two versions share one queue during a rollout, a unit must only be claimed by a worker that can run it. App V5's scheduler filters claims to task names the claiming process registered (lib/scheduler/scheduler.py:276-286), explicitly so a mixed-version fleet does not have an old worker steal a task it cannot execute; a push queue gets the same property from a per-version route or queue. Either way, the old one keeps serving until the manifest says it is deprecated (an entry in the manifest's top-level deprecations array with kind: capability, its deprecated_at and replaced_by), and apps see the deprecation in the manifest before it is removed. Removal is a major version bump.

Degrading#

When something a job needs is unavailable:

What is downWhat the module does
the issuernew tokens cannot be minted; in-flight jobs continue (verification is offline); callers retry token exchange with backoff. Never fall open.
a judge variantfall back to self if the manifest allows it and record judged_by: self; otherwise needs_review with reason JUDGE_UNAVAILABLE. Never emit a confidence you did not compute.
an extractor dependencyfall back to the self research variant if the manifest allows it, else needs_review with reason INPUT_MISSING.
a vendor (Oxylabs, Gemini, OpenAI)the poll-with-backoff and rate-limit primitives (04-dispatch-and-workers.md) absorb it; items that exhaust attempts get error with a vendor code; the job completes with errors rather than hanging.
storage or queue/health/deep goes red; new jobs get 503; in-flight units retry per the queue's backstop.
a callback receiverat-least-once delivery retries, then dead-letters with a count; the caller's sweep catches up.

The rule under the table: degrade to a worse-but-honest answer, or to needs_review, never to a silent lie. An outcome always says which variant produced it and which judge scored it, so a degraded run is visible in the records afterwards.

Readiness is declared, never hidden#

A module whose provider is unconfigured still registers, still appears in the manifest, and still describes itself. It reports that it is not ready, names what is missing, and says what would fix it. It does not quietly drop the capability from its manifest.

Hiding a capability because its dependency is missing is a contract violation, not a safety measure. The consumer cannot tell an absent capability from a misconfigured one, so a missing API key presents as a module that does not exist, and the person debugging it goes looking in the wrong place. Owner ruling, 2026-08-22: "we don't want to deregister it, that would be a violation of our contract by hiding the module instead of surfacing the error, hinting at a provider config issue but not actually making it easy to know that or what error or even if the provider is present."

So every module answers a preflight checklist, and the answer travels to the consumer:

  • Each capability declares the dependencies it needs to run, provider credentials included.
  • Preflight checks each one and returns a per-check result: what was checked, whether it passed, and on failure the specific error and the fix.
  • The manifest carries the module's overall ready state beside the per-check detail, so a consumer can render both a summary and the reason.
  • A capability that is registered but not ready is offered to the app, which decides whether to show it, block it, or warn. That call belongs to the app, because only the app knows whether a person is about to rely on the output.
  • A local or development module is not exempt. It takes the same provider configuration and answers the same checklist, so the unconfigured path is exercised in development rather than discovered in production.

This is the registration-level form of the rule under the Degrading table. That one says an outcome degrades honestly rather than lying. This one says a capability's availability is reported honestly rather than vanishing.

Feature flags and per-tenant behaviour#

A module may vary behaviour per tenant (a customer on the old matcher, a customer piloting the judge) but only through the manifest's declared variants and settings, selected per job by the app. A module does not hold per-tenant behaviour configuration itself; the app does, and passes it. The one thing a module does hold keyed by tenant is an operator-set hard ceiling (canon/04, canon/08): a safety floor against runaway spend, set by whoever runs the module, not a customer preference, and it changes nothing about what the module produces. walmart's Live Submit flag (global setting plus per-org DB flag, both required) is the app-side shape (../evidence/20260817-walmart-recon.md §4).

Do-nots#

  • Do not ship a variant whose output schema differs from the capability's. That is a new capability.
  • Do not fall back to a different variant without recording it on the outcome.
  • Do not emit a confidence the module did not compute or receive.
  • Do not remove a capability or a route without a deprecation period the manifest announces.
  • Do not hold per-tenant config inside a module. Take it per job from the app.
  • Do not let a dependency outage hang a job. Complete with errors or review, and say why.
  • Do not deregister, hide, or omit a capability because its provider is unconfigured. Register it, report it not ready, and name the missing piece.
  • Do not report a module ready without running its preflight checks.
  • Do not swallow a preflight warning inside the module. It travels to the consumer or it does not exist.
  • Do not exempt a local or development module from provider configuration and the preflight checklist.
@versable-git/ui · reference, canon, and method, read in place