Operation classes
Contract vs operate vs dual — the formal boundary deciding what an agent may change via the API/MCP and what it must change in the repo.
Contract vs operate vs dual — the formal boundary deciding what an agent may change via the API/MCP and what it must change in the repo.
Every platform operation falls into exactly one of three classes. The class
answers one question an agent asks constantly: can I change this with an
API/MCP call, or must I edit the product repo and push? The taxonomy is a
single typed source of truth (@farthershore/contracts
operation-class.ts) that core's guard, the CLI, the MCP server, and the
dashboard all consume — so the boundary can never be reasoned about
inconsistently.
export type OperationClass = "contract" | "operate" | "dual";
contract — managed as code. Declarative product state that round-trips
with the manifest / ProductSpec and is canonical in the product's GitHub repo:
plans, pricing, routes/features, meters, capabilities, policies, gateway config,
backend definitions, webhook endpoints, and the contractual product fields.
Once the product is ACTIVE and repo-linked, the core API returns
409 MANAGED_BY_CODE on writes (the DRAFT creation window is the seed-the-repo
exception). Over MCP: read-only. An agent changes these by editing the
manifest and pushing — never by an out-of-band call.
operate — runtime operation. An imperative action on the running platform
that is never committed to the repo: runtime tokens, usage reads,
readiness/status, persona/test bootstrap, build and publish triggers, frontend
deploys, runtime knobs, and brand/presentation edits. Over MCP: read + write.
dual — repo or API. Reconciles via either the repo or the API/MCP; both
are first-class and converge to the same state. Preview environments (a git
branch ⇄ an environment) are the canonical example. Over MCP: read + write.
// The decisive test (contract vs operate):
// "Does this state live in the manifest / round-trip with ProductSpec, such
// that publishing from the repo would reconcile or overwrite an out-of-band
// edit?"
// Yes → contract
// No (credential / → operate
// ephemeral / action)
// Both repr. + path → dual
| Class | Canonical home | MCP may write? |
|---|---|---|
contract | repo | no |
operate | api | yes |
dual | both | yes |
One rule governs every MCP/API surface: a tool that writes may not touch
contract-class state. Reads are always allowed. The check is shared code:
export function mcpToolClassError(
operationClass: OperationClass,
sideEffect: "read" | "write",
): string | null {
if (sideEffect === "write" && !mcpMayWrite(operationClass)) {
return (
`An MCP tool that writes cannot have operationClass "${operationClass}": ` +
`${operationClass}-class state is managed as code in the product repo ` +
`(canonical home: ${canonicalHome(operationClass)}). Make the tool ` +
`read-only, or author the change in the manifest and push instead.`
);
}
return null;
}
So fs_plan_list exists (a contract read) but there is no
fs_plan_create — creating a plan is a contract write, which lives in the
manifest. The CLI still exposes farthershore plan create as a convenience
against a DRAFT, but on a published product it returns MANAGED_BY_CODE
(exit 4) and you edit the @Plan member instead.
This is the practical split. A contract change means editing
product/product.config.ts and pushing; everything else is a direct call.
| Operation | Class | How an agent changes it |
|---|---|---|
plan.create / update / delete | contract | Edit @Plan in the manifest, push |
route.define / feature.define | contract | Edit @Feature routes, push |
meter.define | contract | Edit @Meter / @Requests, push |
capability.define / entitlement.define | contract | Edit @Capability / @Entitlement, push |
gateway.config / rate_limit.update | contract | Edit the manifest, push |
backend.define | contract | Edit the backend block, push |
product.contract_field.update | contract | Edit @Product({ name, origin, … }), push |
runtime_token.mint / rotate / revoke | operate | farthershore backend tokens … |
usage.read | operate | farthershore usage summary |
product.status / frontend.status | operate | farthershore product status |
persona.bootstrap | operate | farthershore persona bootstrap |
brand.update | operate | farthershore product update --display-name … |
product.create / publish / rollback | dual | farthershore product create / publish |
environment.create / update / delete | dual | farthershore env … or a branch push |
The contractual product fields are exactly name, baseUrl, sandboxBaseUrl,
meters, subscriberChangePolicy, envBranchPrefix. The presentation fields —
displayName, description, iconUrl, logoUrl, primaryColor,
featuredPlanId — are operate, so an agent may edit branding via the API even
on a code-managed product.
Backends split deliberately. backend.define is the ProductSpec backend
block (transport, verification, routing) — that is contract, edited in the
manifest. backend.create / backend.delete provision a deployment
instance (the tunnel lifecycle) and mint runtime tokens — those are operate,
done with farthershore backend create / backend tokens mint. Same resource,
two classes, because one is the contract and the other is a runtime action.
A product is dual: it is both a repo and a platform record, and
product.create is the bridge. During the DRAFT window — before the product
goes ACTIVE — the API accepts contractual writes so the agent can seed the
repo (this is what plan create does at scaffold time). Once published, the
repo is canonical and the guard engages. The mental model: seed via the API,
then operate as code.
When any call returns exit 4 / 409 MANAGED_BY_CODE, the change you attempted
is contract-class. Do not retry the API. Instead:
# 1. Edit the decorated definition (e.g. raise the Pro plan's price).
# product/product.config.ts → @Plan("pro", { price: { amount: 24900, … } })
# 2. Validate locally — same gates as publish.
farthershore build --format json
# 3. Commit and push; the GitHub bot compiles and applies the change.
git commit -am "raise Pro price" && git push
See the end-to-end walkthrough for the full decorated
@Product class, and the MCP page for which operations surface
as fs_* tools.