Skip to content

Converging Lifecycle

llms.txt lists every page for an agent
Optional: hand this page to your coding agentThe steps work by hand too.
Show the whole prompt
Read https://intentius.io/chant/guide/converging-lifecycle/.
Write the ConvergeOp I describe as a `*.op.ts` file in this project, with `dial: "observe"` unless I name another dial. Give every rule a `why`, and give the Op a `budget`.
Run `npx chant build` and `npx chant lint` until both pass, and fix every OPS014 finding by changing the rule, never by removing its `why`.
Open a pull request with the Op. In its description, list each rule with the Op it dispatches and the dial it runs under.
Never deploy to a real cloud account or shared environment (with `chant run`, `chant components` or a platform's own CLI), approve (a pull request review, `chant approve` or `chant run approve`), override a policy denial (`chant approve policy-override`), or merge; never cut or publish a release (`chant release cut --yes` or `chant release publish`), push or delete a `ci/*` tag (`chant ci tick` pushes them), or change the value of an existing flag; never touch `.chant/allowed_signers` or `chant/lifecycle`.

Watching tells you an environment drifted. Reconciling closes one gap, once, on request. ConvergeOp runs on a schedule between those two: each tick observes, matches what it saw against written rules, and runs what they say, up to a per-tick budget.

The declaration is the goal, and the status join says how the live state differs from it. ConvergeOp adds one thing, with no goal language or planner: a typed table from a symptom to a verb.

import { ConvergeOp, eq, gt, run, report, when } from "@intentius/chant/op";
import type { ConvergeSymptom } from "@intentius/chant/lifecycle/symptoms";
export const { op } = ConvergeOp({
name: "staging-converge",
env: "staging",
dial: "apply", // "observe" | "reconcile" | "apply" — default "observe"
budget: 3, // max dispatches per tick — default 3
schedule: "*/10 * * * *", // the cadence on the Op; omit for one-shot ticks only
rules: [
when<ConvergeSymptom>(eq("status", "drifted"), run("staging-apply"), {
id: "drift-apply",
why: "Live config drifted from declared source; re-apply converges it back.",
}),
when<ConvergeSymptom>(gt("adoptCount", 0), report("unowned resources present"), {
id: "adopt-report",
why: "An unowned resource is reported for a human to review — never auto-claimed.",
}),
when<ConvergeSymptom>(eq("status", "unknown"), report("environment could not be fully observed"), {
id: "unknown-report",
why: "unknown never remediates — a partial read is a hole to report, not a guess to act on.",
}),
],
});
export default op; // discovered by `chant run staging-converge`

Every rule is a when(predicate, action, { id, why }): a typed comparison over a ConvergeSymptom (never an arbitrary function; see Rules are data below), an action (run(opName) to dispatch a declared Op, or report(reason) to log-and-ledger only), and a mandatory why. A rule with no why fails chant build.

Phases: Observe -> Converge.

  1. Observe: lifecycleSnapshot + lifecycleDiff --live, the same pair WatchOp runs, recording a Drift outcome on the tick’s run record.
  2. Converge: one convergeTick activity: derive the typed symptom (the status join + change-set counts + unobserved reasons), evaluate every rule against it, dispatch matched run() rules up to budget via the existing local runner (chant run <op>), and append one record to the converge ledger.

chant run staging-converge runs a single tick here, which is also how you test it. schedule is the cadence on the Op, and the thing that fires it is your choice: chant operator ticks every discovered ConvergeOp from a loop on git alone (see the operator guide), a generated CI workflow ticks it from a cron job, and a fountain Steward ticks it in its own thread.

Some resources are declared nowhere a lexicon can observe them. The services a box runs under its supervisor are one example. That supervisor is sprite-env on a sprite, or a stand-in with the same command line. For these, observe takes the place of the lexicon observation (#2778). It is an activity step that returns one verdict per resource:

{ resources: [{ name: "app", status: "drifted", detail: "stopped" }, { name: "hud", status: "in-sync" }] }

status is in-sync, drifted or unknown. A shell() step that prints that object as JSON works too. The fly lexicon’s spriteServicesObserve is the observer for a box’s services. It reads the supervisor’s list and probes each declared service’s health URL.

The box declares its services in its member’s box block in chant.workspace.json (#2880), as in this member entry.

{
"name": "box",
"dir": "box",
"kind": "other",
"because": "the box's steward and its Ops",
"box": {
"services": [
{ "name": "app", "cmd": "${HOME}/box/run-app.sh", "duration": "3s", "health": "http://127.0.0.1:5173/health" },
{ "name": "hud", "cmd": "${HOME}/box/run-daemon.sh", "needs": ["app"], "duration": "3s", "health": "http://127.0.0.1:8081/__hud/health" },
{ "name": "site", "cmd": "${HOME}/box/run-site.sh", "health": "http://127.0.0.1:3300/health", "optional": true }
]
}
}

With box: true, the observer and the restart read that list from the member whose directory holds the working directory, on every run. The observer skips site, which is optional, until the supervisor has it.

import { ConvergeOp, Op, eq, phase, report, run, when, type ResourceSymptom } from "@intentius/chant/op";
import { spriteApplyServices, spriteServicesObserve, spriteServiceRestart } from "@intentius/chant-lexicon-fly";
export const { op } = ConvergeOp({
name: "converge",
env: "box",
dial: "apply",
schedule: "* * * * *",
observe: spriteServicesObserve({ box: true }),
rules: [
when<ResourceSymptom>(eq("status", "drifted"), run("restart-service"), {
id: "restart-drifted",
why: "A declared service that stopped, or stopped answering its health URL, is restarted through its supervisor.",
}),
when<ResourceSymptom>(eq("status", "unknown"), report("a service could not be observed"), {
id: "report-unknown",
why: "unknown never remediates.",
}),
],
});
export default op;
// restart-service.op.ts: the service is the one the rule fired for.
export const restartService = Op({
name: "restart-service",
overview: "Restart the service a converge rule names, and wait for its health URL.",
phases: [phase("Restart", [spriteServiceRestart({ box: true })])],
});
// services.op.ts: define the declared services, run when the box is provisioned.
export const services = Op({
name: "services",
overview: "Create or replace the box's declared services through sprite-env, and start them.",
phases: [phase("Apply", [spriteApplyServices({ box: true, start: true })])],
});

spriteServicesObserve and spriteServiceRestart also take the list inline as services, or from a JSON file as servicesFile, in place of box: true. spriteServiceRestart restarts a service the supervisor already has. spriteApplyServices({ box: true }) runs inside the sprite and defines the services through sprite-env services, in dependency order. It creates a declared service the supervisor doesn’t have, and deletes and creates one whose cmd, needs or httpPort differ from what sprite-env services list reports. It leaves a converged one as it is and deletes nothing the block doesn’t declare. only: [names] applies some of them, and an optional service is applied only when only names it. start: true then starts each applied service that isn’t running, and restart: true restarts the applied ones that were already converged. Its result names what it did to each service: created, replaced, restarted, started or left. A release Op that ships a site applies it with spriteApplyServices({ box: true, only: ["site"], start: true, restart: true }).

The rules read a ResourceSymptom (env, resource, status, detail) and are evaluated once for each resource. A run() that fires is dispatched for that resource. The dispatched run reads its name from CHANT_CONVERGE_RESOURCE, which spriteServiceRestart uses when it is given no name. The rest of this page holds per resource: unknown never remediates and flap damping counts one rule’s fires for one resource. budget still bounds the dispatches of the whole tick. The dial × verb class matrix below applies to the dispatched Op as it does to any other. The tick’s ledger record lists the resources and their verdicts in resources, and each outcome names its resource. chant workspace status --json shows a steward’s ConvergeOp’s newest tick as lastTick.

ConvergeOp adds no authority an environment didn’t already grant. Every Op a rule’s run() dispatches is classified read-only / mutating / destructive from its own composition (its steps, its deleteMode, whether it carries a gate), never self-reported. Issue #1484’s own Autonomy table is the target:

verb classobservereconcileapply
read-onlyfree-runfree-runfree-run
mutatingreport onlyopen PRrun, gated per op
destructiverefusedrefusedalways gated

v1 implements a conservative subset of this:

verb classobservereconcileapply
read-onlyfree-runfree-runfree-run
mutatingreport onlyrefused at build (not yet implemented)run
destructiverefusedrefusedrefused at build, v1
  • reconcile × mutating is “open PR” in the issue’s table. Building that channel, reusing ReconcileOp’s onDrift: "pull-request" | "issue" | "comment" | "report", is out of v1 scope (epic #1487’s onDrift-channel open question). Until it exists, a rule that would dispatch a mutating op under reconcile is refused at build (OPS014), not silently escalated to run directly the way apply would. convergeTick re-checks the same thing at dispatch time, as a runtime backstop for a rule table that reached the tick without going through that build.
  • apply × destructive is “always gated” in the issue’s table, but v1 refuses it outright, gate or not, at build (OPS014). A converge tick runs unattended, on a schedule, with no one watching it fire. A destructive dispatch needs a human’s approval before the tick commits to running the op, and run() has no way to ask first, so the rule is refused.
  • apply × mutating + gated is reachable, because OPS014 refuses only destructive-with-a-gate. A tick that dispatches such a rule sees the dispatched op stop at its gate and exit 3. The tick treats that as gate-as-fact: it records action: "gated" and moves on without reporting an error. See the operator guide’s “Gate-as-fact” section for the resolution path (chant approve, or a merged PR) and what the next tick does with it.

dial defaults to "observe", so a ConvergeOp with no dial set only reports.

chant build refuses a rule table with any of these, via OPS014 (rules reference):

  • a rule with no (or a blank) why,
  • a predicate outside the evaluable subset, which is eq/neq/gt/gte/lt/lte/truthy/falsy/allOf/anyOf over a field ConvergeSymptom actually produces (ResourceSymptom for a ConvergeOp with observe); nothing else,
  • run() naming an Op that doesn’t exist,
  • a mutating dispatch under any dial other than apply (see the matrix above; reconcile’s “open PR” isn’t implemented in v1),
  • a destructive dispatch under any dial, unconditionally (an unattended tick can’t ask for approval first; see above),
  • a rule whose predicate reads adoptCount while dispatching a mutating op (adopt safety, below).

Duplicate rule ids, an empty rule table, and a negative/non-integer budget are refused directly in the ConvergeOp factory.

  • unknown never remediates. Whatever a rule’s own predicate says, a tick whose symptom is status: "unknown" forces every matched run() action to report instead. convergeTick enforces this itself instead of trusting rule authors.
  • Adopt safety is enforced in code. There is no dispatch verb that claims an unowned resource, and OPS014 refuses a rule that both reads adoptCount and dispatches a mutating op. Precisely, a rule predicated on adoptCount may only report(), or dispatch a read-only op. Separately, buildChangeSet never classifies an unowned/undeclared resource as a delete/update proposal in the first place, so nothing in the ordinary apply path can mutate an adopt candidate either.
  • The worst tick is bounded. budget caps run() dispatches per tick; anything matched beyond it is recorded skipped-budget, not queued.
  • A prior tick is never queued. The Op’s schedule carries overlap: "skip": if a tick is still running when the next fire time arrives, the new one is dropped, not buffered (the issue’s “skip-and-report … never queue”). There is no per-tick ledger record for the skipped fire itself (nothing ran, so nothing observed, classified, or dispatched). chant operator reports it as skipped=1(lease-held:<holder>) for that round; a steward reports it on the thread as teammate was busy.
  • Flap damping is mandatory. A rule that fires flapThreshold (default 3) consecutive ticks without its symptom clearing escalates to skipped-flap and stops dispatching. The counter lives on the converge ledger, not in process memory, so it survives the process that was ticking.
  • Every action traces to a rule, in source. One log line and one ledger record per tick (<env>/converge.jsonl on the chant/lifecycle branch); git blame on the rule table answers “why did it do that.”

A rule’s when looks like a predicate but is JSON: eq("status", "drifted"), not s => s.status === "drifted". A rule table has to survive chant build into op.json and be read back by something that never imported this project, and a closure cannot survive that trip. That is why a rule outside the evaluable subset is refused. isWellFormedPredicate is the runtime backstop OPS014 re-checks a rule table against, in case one was assembled by hand instead of through when().

  • Ops — the Op abstraction ConvergeOp composites
  • Reconciling Lifecycle — ReconcileOp / ApplyOp, the two directions a converge rule typically dispatches to
  • Watching Lifecycle — the observation ConvergeOp’s Observe phase reuses
  • Operator — chant operator: the local runtime that ticks a ConvergeOp on a schedule, durably, on git alone

chant