Converging Lifecycle
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`.Optional: hand this page to your coding agent
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.
Defining a converge loop
Section titled “Defining a converge loop”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.
What one tick does
Section titled “What one tick does”Phases: Observe -> Converge.
- Observe:
lifecycleSnapshot+lifecycleDiff --live, the same pairWatchOpruns, recording aDriftoutcome on the tick’s run record. - Converge: one
convergeTickactivity: derive the typed symptom (the status join + change-set counts + unobserved reasons), evaluate every rule against it, dispatch matchedrun()rules up tobudgetvia 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.
Converging resources no lexicon observes
Section titled “Converging resources no lexicon observes”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.
The dial × verb class matrix
Section titled “The dial × verb class matrix”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 class | observe | reconcile | apply |
|---|---|---|---|
| read-only | free-run | free-run | free-run |
| mutating | report only | open PR | run, gated per op |
| destructive | refused | refused | always gated |
v1 implements a conservative subset of this:
| verb class | observe | reconcile | apply |
|---|---|---|---|
| read-only | free-run | free-run | free-run |
| mutating | report only | refused at build (not yet implemented) | run |
| destructive | refused | refused | refused at build, v1 |
reconcile× mutating is “open PR” in the issue’s table. Building that channel, reusingReconcileOp’sonDrift: "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 underreconcileis refused at build (OPS014), not silently escalated to run directly the wayapplywould.convergeTickre-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, andrun()has no way to ask first, so the rule is refused.apply× mutating + gated is reachable, becauseOPS014refuses 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 recordsaction: "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.
Build-time refusals
Section titled “Build-time refusals”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/anyOfover a fieldConvergeSymptomactually produces (ResourceSymptomfor a ConvergeOp withobserve); 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
adoptCountwhile 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.
Honesty requirements
Section titled “Honesty requirements”unknownnever remediates. Whatever a rule’s own predicate says, a tick whose symptom isstatus: "unknown"forces every matchedrun()action to report instead.convergeTickenforces this itself instead of trusting rule authors.- Adopt safety is enforced in code. There is no dispatch verb that claims an unowned resource, and
OPS014refuses a rule that both readsadoptCountand dispatches a mutating op. Precisely, a rule predicated onadoptCountmay onlyreport(), or dispatch a read-only op. Separately,buildChangeSetnever classifies an unowned/undeclared resource as adelete/updateproposal in the first place, so nothing in the ordinary apply path can mutate an adopt candidate either. - The worst tick is bounded.
budgetcapsrun()dispatches per tick; anything matched beyond it is recordedskipped-budget, not queued. - A prior tick is never queued. The Op’s
schedulecarriesoverlap: "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 operatorreports it asskipped=1(lease-held:<holder>)for that round; a steward reports it on the thread asteammate was busy. - Flap damping is mandatory. A rule that fires
flapThreshold(default 3) consecutive ticks without its symptom clearing escalates toskipped-flapand 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.jsonlon thechant/lifecyclebranch);git blameon the rule table answers “why did it do that.”
Rules are data, not closures
Section titled “Rules are data, not closures”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().
See also
Section titled “See also”- Ops — the Op abstraction
ConvergeOpcomposites - 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 aConvergeOpon a schedule, durably, on git alone