Migrating from 0.7.x to 0.8.0
What changed between Routecraft 0.7 and 0.8, and how to update.
0.8.0 is the plugin release. Every feature Routecraft ships beyond the route grammar is now a plugin built on the same sockets a third party uses: direct endpoints, retries, caching, authentication and deferral included. Routes built from the DSL are unchanged. What follows is every change that can break a 0.7 project, in the order most projects meet them.
- Plugins are descriptors.
CraftPlugin,apply(ctx)andteardown(ctx, info)are removed; a plugin isdefinePlugin({ id, bind, start, stop, ... })and never receives theCraftContext. Section 1. ex.principalisex.auth.principal. Section 2.craft.config.tsdefault-exportsdefineProject(...). A plain config still loads. Section 3.registerDslis removed. Route methods come from a plugin'ssteps. Section 4.- Store keys that crossed plugins are ports. Section 5.
- A custom
DeferralStorefences its claims.renewClaimis new, andmarkExpiredandmarkDeniedtake the claim id. The shipped SQLite and memory stores migrate themselves. Section 6. - An editor turn's body is the prompt text. An agent's own
userthat readex.body.messageon an ACP turn readsex.body. Section 7.
Projects that write no plugin of their own typically need section 2 and nothing else, section 6 if they supply their own DeferralStore, and section 7 if an agent declares its own user for editor turns. Section 8 lists what is new, for context.
1. Plugins
A plugin is a plain descriptor built with definePlugin(). It declares a dotted id, and its lifecycle functions receive a PluginContext: ports, events, its own routes and a small set of execution verbs. A plugin still shaped with apply is refused at install with RC1117 naming the change.
import { type CraftPlugin } from '@routecraft/routecraft'
export const audit: CraftPlugin = {
apply(ctx) {
ctx.on('route:exchange:failed', ({ details }) => {
ctx.logger.warn({ route: details.routeId }, 'exchange failed')
})
},
}
import { definePlugin } from '@routecraft/routecraft'
export const audit = definePlugin({
id: 'acme.audit',
bind(c) {
c.observe('route:exchange:failed', ({ details }) => {
c.logger.warn({ route: details.routeId }, 'exchange failed')
})
},
})
bind runs at the same point apply did: while the application is installed, before routes are registered. Plugins now bind in dependency order rather than strictly in list order: a plugin that requires a port binds after the plugin that provides it. The default plugins (routecraft.direct, routecraft.resilience, routecraft.cache, routecraft.principals, routecraft.auth) install ahead of the application's own, so a lifecycle event's pluginIndex counts them.
Two new families of fault name the plugin responsible: RC1101 to RC1117 for install, resolution, ordering and compile faults, and RC5068 when a validate hook refuses an exchange. See the errors reference.
2. ex.principal is ex.auth.principal
The authenticated principal is now the auth plugin's facet. In a route callable, read ex.auth.principal. Code that holds a plain Exchange (an adapter, a hook, a helper) reads principalOf(exchange). The header it derives from, routecraft.auth.principal, is unchanged.
craft()
.id('whoami')
.from(direct())
.authenticate(() => ({ scheme: 'test', subject: 'ada' }))
.transform((_body, ex) => ex.auth.principal?.subject)
.to(log())
ex.deferral is unchanged in route callables; outside them, read deferralOf(exchange). The principal authority (mint, brand, isAuthentic, restore, isRestored, read) is the AUTHORITY port the default routecraft.principals plugin provides. Brand and check through authorityOf(exchangeOrContext) instead of markAuthentic, isAuthentic, markRestored and isRestored, which are no longer exported; the default authority is exported as defaultAuthority for a replacement that decorates it, and the restrict-principal-minting lint rule flags its mint() and brand() too. delegate() takes the authority as its fourth argument: delegate(subject, actor, options, authorityOf(exchange)).
3. defineProject
A project declares its plugins and configuration with defineProject(), and craft.config.ts default-exports the result:
// craft.config.ts
import { defineProject } from '@routecraft/routecraft'
export default defineProject({
plugins: [],
deferral: {},
})
defineProject returns { craft, config, plugins }. Its craft() is typed by exactly the installed plugins, so a route can use a plugin's steps and facet. A named craftConfig export or a default-exported config object still loads, so this change is not required; craft start reads a project first. See Plugins.
4. Route methods come from steps
registerDsl and augmenting StepBuilderBase are removed. A plugin's steps become builder methods, typed by step<In, Out>():
import { definePlugin, step } from '@routecraft/routecraft'
export const shout = definePlugin({
id: 'acme.shout',
steps: {
shout: () => step<string, string>((exchange) => exchange.body.toUpperCase()),
},
})
Build routes that use it with the project's craft(). A method generic at the call site declares its type by merging into StepMethods. See Steps.
5. Store keys that crossed plugins are ports
State one plugin hands another, or hands an adapter, is a port: a typed token declared once with port<T>("owner.capability@1"). These store keys are removed:
A custom adapter that read context-wide defaults from a store key reads them from a port its companion plugin provides: context.lookup(MY_DEFAULTS). See Merged Options. An adapter's own per-context state may stay in the store.
CraftConfig.plugins is a readonly array.
A plugin that is not repeatable is installed once per application. A second llmPlugin(), embeddingPlugin() or shellPlugin() used to replace the first silently; it is now RC1101. Merge the options into one install.
The internal RouteDefinition fields preParseFilters, postParseFilters and postFromFilters are gone: a route definition carries its chain as configuration, and the chain is built when the route compiles.
6. A custom DeferralStore fences its claims
A store supplied through deferral: { store } implements a claim with an identity, so a claimant that outlived its lease cannot renew or finalize the claim that replaced it. The shipped backends migrate themselves (the SQLite file moves to schema version 3 on open); a store of your own changes four members:
The fenced methods compare the claim's identity and nothing else. Compare renewedAt too and the holder's own second renewal loses, because its copy of the claim went stale at the first. The exported claimedBy(record, claimId) predicate is that compare; resumable and claimed read claim in place of claimedAt. The kernel renews a claim three times per expiryLease while a re-ask runs, so a slow .error() handler no longer needs a lease longer than itself.
A configured store missing any of these members is refused when the application starts, with RC5066 naming the first one, rather than starting clean and losing its first delivery claim to a renewal that throws.
Do not run 0.7 and 0.8 against one SQLite file, including for the length of a rolling deploy: 0.7 does not honour the fence, so it can settle a delivery a 0.8 process is still making. Stop every 0.7 process before the first 0.8 one opens the file. A claim a 0.7 process writes after the migration anyway carries no identity; 0.8 reads it back as claimed under one no holder can present, and the lease releases it from its claim time, so the record is redelivered rather than stranded.
7. An editor turn's body is the prompt text
An ACP turn used to reach the agent's route as a { session, message } body, so an agent with no user of its own, as every agent loaded from agents/*.md is, handed the model that envelope as JSON. The body is now the prompt text. An agent that declared its own user for editor turns reads the text directly:
A conversation recorded under 0.7 keeps its earlier prompts as they were stored, so loading one in the editor replays those as the envelope; turns taken under 0.8 replay as text.
8. What is new in 0.8.0
- Ports. Plugins share anything through
port<T>(), withrequires,optional,providesandreplaces. - Replaceable positions.
.authorize(), the resilience operations and.cache()are unchanged on the builder, and the default plugins fill them throughENFORCEMENT,RESILIENCEandCACHE. A plugin may replace one, and the replacement fills the same methods placed after.from()too. - Hooks. Plugins add hooks to the slots of the chain (
beforeAuth,afterAuth,admitted,perAttempt,exit,error) in theobserve,mutateandvalidatephases. Every hook declares anid;hooks.orderandhooks.disablein config address it aspluginId/idand settle conflicts. A validate hook'srefuse(reason, { kind })is answered at the door the caller came through with the status its kind maps to. - The
errorslot. A plugin hook can recover, drop or park (recovery.defer()) a failure the route's own.error()did not settle. - Error-path parks are validated.
.error(handler, { schema })and an error hook'sschemadeclare what a resume payload must satisfy when the handler parks; the resume door validates it (RC5049) and refuses a resume whose schema changed (RC5048). - Facets. A plugin's facet is readable as
ex.<namespace>.
The Plugins guide explains each part with a worked example, and the Plugins reference lists every field.