Skip to content

Observation Contract

This is the result shape behind Implementing Observation: what each verdict means, which reasons are legal, and how coverage is tracked. For the walkthrough of writing a reader against a real transport, start there.

The observation tri-state: absent is not the same as unread

Section titled “The observation tri-state: absent is not the same as unread”

Returning nothing for a declared entity is a claim, and there are two very different claims to make:

VerdictHow you report itWhat chant does with it
Observed presenta key in resourcesdrift comparison, noop / update
Observed absentin neither map — you asked, the provider said noMISSING in the diff, create in the plan
Not observeda key in unobserved, with a reasonreported as a hole; never a create or a delete

Only the middle row may become a create. Before chant #1089 the third row was indistinguishable from the second, so a Kubernetes CRD with no reader — or a read that failed on an expired token — arrived at chant lifecycle plan as a confident proposal to create something that was already running.

Report a hole with the ObservationResult envelope:

import { observation } from "@intentius/chant/observation";
import type { ObservationResult, UnobservedEntity } from "@intentius/chant/lexicon";
const resources: Record<string, ResourceMetadata> = {};
const unobserved: Record<string, UnobservedEntity> = {};
try {
const { stdout } = await execAsync(cmd);
resources[entityName] = { type: entityType, /* ... */ };
} catch (err) {
// Only a real not-found leaves the entity out. Everything else proves
// nothing about whether the resource exists.
const outcome = classifyKubectlFailure(err);
if (outcome.kind === "unobserved") {
unobserved[entityName] = { type: entityType, reason: outcome.reason, detail: outcome.detail };
}
}
return observation(resources, unobserved);

The reasons are total — pick one:

ReasonWhen
read-failedthe provider was reached and the read errored
no-credentialsno usable credentials for the target
no-bindingthe environment resolves to no concrete target (no kubectl context, no subscription, no stack)
unsupported-kindyour lexicon has no reader for this entity type
filteredthe resource was reached but withheld by owned: true (it exists, it just isn’t chant’s)

Returning the bare Record<string, ResourceMetadata> map is still valid and means “everything I was asked about, I looked at”. Use it only when that is true.

Throwing is the whole-lexicon failure — core catches it and marks every declared entity read-failed, so a broken read never arrives downstream as an empty environment. Failing the whole snapshot because one Deployment is gone still defeats the point; catch per-resource errors and classify them.

Say where you looked: the resolved query address

Section titled “Say where you looked: the resolved query address”

An absence is only as trustworthy as the address behind it. When the read address is derived — a Kubernetes namespace defaulted from the context, an endpoint override, a region, an account — “the provider said no” and “I asked the wrong place” produce identical verdicts, and the consumer cannot tell them apart. The motivating case (chant #1620): a Flux-deployed object declares no metadata.namespace because the controller stamps targetNamespace at apply time, so the live read scopes to default, finds nothing, and correctly reports absence — painting weeks-old running infrastructure as pending.

The envelope carries an optional queried map for this — the resolved address each read was actually issued against, keyed by entity name:

const queried: Record<string, string> = {};
// k8s: the exact request path, namespace defaulting made visible
const address = await client.pathFor(ref); // "/apis/apps/v1/namespaces/default/deployments/web"
if (address) queried[entityName] = address;
return observation(resources, unobserved, queried);

Rules:

  • Purely additive. queried never changes a verdict; classification reads only resources and unobserved, and the tri-state above does not shift. An entity in queried and neither map is still observed-absent.
  • It is the only record an absence gets. Absence is spelled “in neither map”, so there is no row to hang diagnostics on — the queried map is where an absent verdict says which address answered not-found.
  • Unobserved entries can carry it inline via UnobservedEntity.queried, so a failed-read row renders without a join.
  • Omitting it stays valid. A lexicon with nothing derived about its addresses can skip it entirely.

The observer harness collects it for you: every EntityObservation variant (present, absent, unobserved) accepts an optional queried string.

Downstream, chant lifecycle diff --live --json passes the map through as resources.queried per lexicon and joins it onto unobserved rows, so a consumer can render queried: /apis/apps/v1/namespaces/default/deployments/web → 404 next to a pending verdict — see chant lifecycle.

Unknown entity types are unobserved, not absent

Section titled “Unknown entity types are unobserved, not absent”

Lexicons grow new resource types over time. If your describe path doesn’t cover a type yet, say so per entity — a warning alone is invisible to lifecycle plan, which is exactly where the wrong create gets proposed:

const operation = operationFor(entityType);
if (!operation) {
unobserved[entityName] = {
type: entityType,
reason: "unsupported-kind",
detail: `no generated operation surface for ${entityType} — run \`chant generate\``,
};
return;
}

Better still, arrange for the gap not to exist. The K8s lexicon’s coverage went from twenty types to every generated one by deriving the address table from the same codegen pass as the classes, so “your lexicon grew a type the describe path does not cover” stopped being a state it can be in. unsupported-kind is then the honest answer for a genuinely unaddressable type, not the routine one.

If your read path can’t determine ownership, stamp ownership: "unknown" on what you return rather than leaving the field off and degrading silently. unknown is a legitimate verdict — the change set never escalates it to a delete. Reserve it for a channel you did not read: AWS’s describe-stack-resources carries no per-resource tags, so its thin read resolves the verdict from the stack’s own tags instead, and unknown is left for the case where that call did not answer.

ownership on a returned resource is one of owned, foreign, or unknown, and the verdict is total — a lexicon that cannot read the marker on a path must say unknown rather than return everything as if it had checked, because the change set never escalates unknown to a delete.

Which paths those are is declared on the plugin (chant #1348):

plugin.ts
ownershipChannel: {
keys: AWS_TAG_OWNERSHIP_KEYS,
reads: ["describeResources", "observeResourcesDeep", "exportResources"],
},

Per read path, because the answer genuinely differs by path — in granularity as much as in availability. aws stamps tags at synthesis and reads them per resource on the deep observation and on live export; its describeResources is sourced from describe-stack-resources, which returns no per-resource tags, so that path reads the STACK’s own tags and every member of a marked stack carries that stack’s identity. Declaring the path is what lets a caller know the answer is available before asking. A warning on stderr afterwards is invisible to lifecycle plan, which is exactly where the wrong delete gets proposed.

Omit the field entirely if you have no marker channel anywhere. That is a real answer, and the suite will hold you to it: every verdict must then be unknown.

LexiconKeysResolves on
awschant:managed-by tagsall three (describeResources at stack granularity)
azurechant-managed-by tagsexportResources
cedarchant:managed-by AVP tagsdescribeResources, exportResources
cplnchant.intentius.io/managed-by tagsdescribeResources
flymanaged-by machine metadatadescribeResources
gcpapp.kubernetes.io/managed-by labelsall three
grafanaapp.kubernetes.io/managed-by labels on a dashboard.grafana.app resource, or the grafana.app/managerId annotation naming a project providerall three, for dashboards over /apis; datasources and Grafana 11 dashboards say unknown
helmapp.kubernetes.io/managed-by labelsdescribeResources, observeResourcesDeep
k3dapp.kubernetes.io/managed-by labelsdescribeResources
k3sapp.kubernetes.io/managed-by labelsdescribeResources
k8sapp.kubernetes.io/managed-by labelsall three
renderCHANT_MANAGED_BY env varsdescribeResources
sqla [chant managed-by=chant stack=… env=…] trailer on the object’s COMMENT, after the declared commentdescribeResources, exportResources

Each lexicon declares its own ChannelKeys (managedBy / stack / env); the label convention is shared as LABEL_OWNERSHIP_KEYS in @intentius/chant/ownership, and each tag-based lexicon keeps its own beside it (lexicons/aws/src/ownership.ts, lexicons/fly/src/ownership.ts, lexicons/cedar/src/avp/ownership.ts).

chant dev check-lexicon fails a declaration that names a path the plugin does not implement, or whose keys are incomplete.

describeObservationConformance in @intentius/chant-test-utils is the shared suite every observing lexicon runs. Give it scenarios driven by your own transport mocks; it checks the result shape, reason totality, ownership totality, and — through core’s real buildChangeSet — that an unreadable entity never classifies as create:

import { describeObservationConformance } from "@intentius/chant-test-utils";
import { k3dPlugin } from "./plugin";
describeObservationConformance({
lexicon: "k3d",
ownershipChannel: k3dPlugin.ownershipChannel,
scenarios: [
{
name: "running owned cluster",
declared: ["probe"],
owned: true,
expectPresent: ["probe"],
run: () => describeResources(options(), fakeExec({ "k3d cluster list": RUNNING_CLUSTER })),
},
],
});

That is lexicons/k3d/src/describe-resources.test.ts verbatim. Pass ownershipChannel whenever your plugin declares one. Without it the suite can only confirm that a verdict, when present, is one of three strings; with it, a lexicon declaring describeResources must answer owned or foreign on an owned read, and a lexicon declaring no channel must answer unknown.

The scenario fields are these.

FieldMeaning
nameTest title.
declaredEntity names the lexicon was asked about, the declared axis fed to buildChangeSet.
runInvokes your describeResources under your own transport fake. Returns a DescribeResourcesResult.
expectPresentNames that must land in resources.
expectAbsentNames that must be in neither map, and that must classify as create.
expectUnobservedNames that must land in unobserved, and that must classify as unobserved rather than create.
ownedThis scenario ran with owned: true, so hold the lexicon to its declared channel.
expectMarkerPer entity, the exact ResourceMetadata.marker (stack + env) the read must surface.
expectNoMarkerNames whose live model carries no marker channel, so the field must stay absent.

Twelve lexicons run it today (aws, azure, cedar, fly, fountain, gcp, grafana, helm, k3d, k3s, k8s, sql). lexicons/fly/src/describe-resources.test.ts and lexicons/aws/src/lifecycle-integration.test.ts are the other two worth reading, for a REST transport and for a stack-granularity marker.

observeResourcesDeep() — property-level drift

Section titled “observeResourcesDeep() — property-level drift”

Ownership is a label on drift, not a filter

Section titled “Ownership is a label on drift, not a filter”

A substrate that records who wrote each field (Kubernetes’ metadata.managedFields, read by the k8s row) may return fieldOwners — a map from path to manager name — alongside each resource’s normalized tree. Core attaches it to every reported drift as owner, and to every unclaimed field as heldBy. It must not be used to drop fields before the diff. A field a foreign manager owns and source never declared is what the unclaimed report exists to show, and pruning it at read time is how chant #1191 lost a console-added label. Subtract only what the platform writes on every object of a kind (status, server-minted metadata, controller-stamped annotations and labels — K8S_SYSTEM_METADATA_PRUNE_PATTERNS in @intentius/chant/managed-fields for Kubernetes-shaped objects).

The claimed-field set is the substrate-independent half

Section titled “The claimed-field set is the substrate-independent half”

Core flattens each declaration’s props into the same path grammar the diff uses, then classifies every live value three ways.

ClassificationResult
declared and equalNothing to report.
declared and differentDrift. The only case that may become an update.
undeclaredReported under unclaimed. Never drift.

No lexicon implements it. Where you return fieldOwners the unclaimed row names the manager and says source: "field-manager". Where you do not (or for a path your ownership metadata does not cover) the claim answers instead, and the row says source: "claimed-fields". See the authoring guide.

Some deviations are permanent facts of the account — a platform team’s mandatory tag, a setting an org policy flips on. chant lifecycle diff <env> --live --update-baseline records what a run reported into <env>/observation-baseline.json on the chant/lifecycle orphan branch, and later runs subtract it. Acceptance is value-bound: the accepted value stops alerting, a change away from it is drift again, reported with all three axes (declared / live / baseline). Nothing in a lexicon has to implement this — it is applied by core, above the reader.

describeResources() answers “what do I manage”. Three further readers answer questions it structurally cannot, because all of them resolve outward from what was declared. Each is opt-in and additive: a lexicon implementing none behaves exactly as it does today.

ReaderImplemented by
observeDependencies()aws
observeAmbient() + ambientKinds()aws, cedar
describeStackStatus()aws, helm, k8s

observeDependencies() — what the estate relies on

Section titled “observeDependencies() — what the estate relies on”

A shared subnet, an account’s default VPC route tables, a network another team owns. The estate references them and does not declare them, so they never become nodes, so no edge can reach them and no fold can traverse them. That is why derived facts about un-modelled topology have had to be computed inside lexicons and injected as attributes.

The closure rule is depth one by reference, plus whatever chains your referenceCatalog declares as meaningful — aws follows SubnetId -> RouteTableId -> GatewayId because the catalog says those references matter, not because they happen to be reachable. Without a rule, a VPC transitively reaches most of an account.

Every resource you return must carry referencedBy, naming the declared nodes that pulled it in. A dependency with no referrer is unbounded discovery, which is the thing the closure rule exists to prevent.

observeAmbient() and ambientKinds() — what is simply there

Section titled “observeAmbient() and ambientKinds() — what is simply there”

An unattached security group, an orphaned volume, the default security group AWS creates per VPC. Nothing declares them and nothing points at them, so neither of the readers above can see them — and “which of my security groups are unused” cannot be answered from a state file at all, because a state file knows only what it created.

kinds bounds the scan to types the project actually declares, so a project managing security groups is not made to enumerate the account. Return resources marked ambient: true, and exclude anything already in observed — those are managed, not ambient.

ambientKinds() is declared separately from the reader so a caller can know that ambient resources of a kind are possible without paying for a scan to find out. chant search uses it to point out that --ambient is relevant to the kind just queried: an agent asking which security groups are unused otherwise has no way to know that some are not in the answer at all.

teardownOwned() — the would-delete set, and its holes

Section titled “teardownOwned() — the would-delete set, and its holes”

The delete-side reader, and the fourth place UnobservedReason appears. chant lifecycle teardown <env> calls it to plan; it is read-only, and every candidate it returns must have been read carrying this project’s marker identity on this lexicon’s channel. A kind the lexicon stamps but cannot read back becomes a TeardownHole with the same total reason vocabulary, because “absent from the plan” reads as “safe” and an unreadable kind is unknown. Implemented by aws, fly and k8s; a lexicon without it still takes part, because core falls back to describeResources filtered on ResourceMetadata.marker. The execution half is executeTeardown(), and the CLI surface for both is chant lifecycle teardown.

describeStackStatus() — one deploy unit, by deployed name

Section titled “describeStackStatus() — one deploy unit, by deployed name”

describeResources() observes a stack’s entities keyed by chant entity name and assumes one stack per environment, which cannot see a multi-stack component project where each component owns its own stack. chant components status --live resolves a component’s deploy-step target and calls this to learn whether that unit is present and healthy.

Return null when you cannot determine status — a provider CLI failing for a reason other than “does not exist”. A genuinely absent unit is { present: false }. The distinction matters for the same reason the observation tri-state does: “I could not look” and “it is not there” support different conclusions.

Two-way diff: there is no “declared” axis

Section titled “Two-way diff: there is no “declared” axis”

lifecycle diff --live reports four artifact categories per lexicon: added, removed, changed, unchanged — comparing now vs. last snapshot, not declared vs. observed. That’s all the diff engine can do without a chant entity to anchor each artifact. Don’t try to forge an entity-keyed mapping just to fit describeResources() — the artifact concept is the right shape for tooling that creates runtime state outside chant.

The three methods above report what a substrate was asked and what it answered. predictBehaviour() reports what no substrate holds, which is how the declared estate would behave at a traffic level the caller names. Per entity it returns cost per hour, headroom on CPU and latency, and an expected error rate. It also returns a resilience verdict under a named failure and an optional right-size hint.

Options mirror observeResourcesDeep() field for field, plus traffic, edges and edgeCoverage. The engine behind it is handed the resource graph and the traffic level and nothing else.

It is given no credential, and the contract works at that on two levels. The type declares every obvious name ?: never, which catches the deliberate attempt at compile time. screenBehaviourRequest then walks the whole request at runtime, reading values as well as key names, which is what catches the accident. The accident is the realistic one, because entities[*].props is Record<string, unknown> straight out of the build and no type on this contract can see into it. A lexicon surfacing a connection string puts one there without deciding to.

The walk detects three things:

  • a value that reads as a credential whatever the key is called. That means a PEM block or a JWT or an Authorization value, and it means a token carrying the issue prefix of GitHub, GitLab, OpenAI, Stripe, Slack, AWS, Google or npm
  • a password in userinfo, with or without a scheme, since a DSN is usually written app:hunter2@db:5432/prod and new URL() will not parse that at all
  • a key whose name reads as a credential, but only when its value is a string of at least eight characters that is not an evident reference, and only when the key is outside the reference family

The first two throw. The third refuses, and the difference is deliberate. Throwing is the whole-lexicon failure, and the name layer is a heuristic: when it threw, one tags: { author: "…" } anywhere in an estate killed the entire overlay with a stack trace. A suspicious name now returns a BehaviourRefusalReport with cause credential-in-request, which is this contract’s own answer to “no faked numbers” applied to its own guard.

Because the two outcomes differ, there is one entry point and your method opens with it:

const refusal = screenBehaviourRequest("acme", options);
if (refusal) return refusal;

assertNoCredentialInOptions is the value-shape half on its own. Calling it instead applies one rule of three and discards the rest, which is how a token nested past the walk’s depth budget, and an awsSecretAccessKey sitting in props, both travelled with a request that was then sent. The function’s own doc comment in behaviour.ts is the source of truth for this sequence.

The gating on that third layer is what makes it usable. An ungated substring match on SECRET|TOKEN|PASSWORD|AUTH refused 406 aws keys when it was run over every property key in chant’s own generated schemas. It refused 173 azure ones. imagePullSecrets, secretName and secretKeyRef reference a Secret by name. ClientToken is an idempotency nonce. Not one of CertificateAuthorityArn, authorizedNetworks or passwordPolicy holds a secret. A test now runs all four lexicons’ keys through the guard and requires zero refusals.

Treat the name layer as a fast path rather than a safety net. It fires on very little once gated, and a credential under a benign name such as dsn is caught by the value layers or not at all.

What none of it detects is anything outside those forms. The token list is a denylist of formats somebody has added, so an issuer nobody has added still passes, and so does a bare random string or a secret split across two fields. There is no entropy scoring, deliberately: this walks build output full of ids, ARNs and digests, and a heuristic that refused those would refuse real projects rather than protect them.

edges is the one place that mirror breaks. A deep read answers per entity and has no use for neighbours, since a bucket’s live property tree is the same tree whatever reads from it. A prediction is the opposite: headroom, an error rate and a verdict under “one zone lost” are all statements about a path through the estate, and an engine handed nodes alone can only price each box on its own. The field carries IREdge from graph-ir.ts rather than an edge type of the behaviour contract’s own, because that is already the shape both paths produce. collectEdges builds them from declared references and reconstructEdges rebuilds them from observed identifiers, so a second type would put a lossy translation hop on each side of the delta the epic asks for. An empty array is a claim that the estate’s entities reference nothing of each other, so do not pass one for edges you have not computed.

The failure this contract exists to prevent is a modeled figure being quoted as money owed. Four things work against it:

  • Money appears in one shape, PredictedRate, carrying a literal rate: "per-hour" discriminant. There is no field for an amount, a period, an account or an invoice, so an elapsed charge cannot be expressed at all.
  • No figure exists without at, the traffic level it was predicted for.
  • provenance is required on every entity, and its basis is a closed enum of modeled (computed off list prices) or validated (reconciled against a real bill).
  • A refusal is a separate arm of the result union with no figures on it, so an unreachable engine and an estate priced at nothing are different objects rather than the same object with zeroes in it.

Behaviour keeps the three-verdict discipline describeResources() established, on a stricter total. Every entity the caller asked about lands in entities or in unpredicted, and never in neither.

VerdictHow you report itWhat it means
Predicteda key in entitiesthe engine models this kind and produced figures for it
Not predictable for this kindunpredicted, with reason unsupported-kindthe engine has no model for the type. The entity is real and may well cost money; this engine cannot say how much, and returns no figure rather than a zero
Not predictedunpredicted, with another reasonthe run did not get far enough to say

The thin read needs a third position for “the provider was asked and said it is not there”. A prediction has no equivalent answer, so an entity in neither map means the lexicon lost track of one.

BehaviourUnpredictedReason derives from UnobservedReason rather than restating it, so the four shared verdicts keep their exact spelling. It differs twice, both deliberately. no-credentials is excluded, because a read that is handed no credential has none to be missing, and an enum able to say it would invite a lexicon to send an operator hunting for a variable this contract forbids. Four reasons about the predictor itself are added, because no-binding describes the environment resolving to no target and says nothing about the engine.

Those four are one axis split four ways because each has a different remedy, and a refusal exists to be acted on. Set a variable for no-engine. Check the address for engine-unreachable. On engine-out-of-credit somebody has to pay, and no amount of waiting will fix it, while engine-over-quota usually clears on its own once the window rolls over. Folding either of the last two into engine-unreachable would point an operator at an address that is answering perfectly well.

ReasonWhen
read-failedthe engine was reached and the prediction errored
no-bindingthe environment resolves to no concrete target to predict
unsupported-kindthe engine has no model for this entity type
filteredreached but withheld by owned: true
no-engineno variable in the chain names an engine, or the transport needs a token and none is set or the engine rejected it
engine-unreachablea variable named an engine and it did not answer
engine-out-of-creditthe engine answered and refused: the account has no balance
engine-over-quotathe engine answered and refused: a rate or volume limit is spent

An unconfigured engine produces a BehaviourRefusalReport, and so does a configured engine that does not answer. Neither one produces an empty or zeroed report. The variables are read most specific first, in the style gitlabNoteTokenFrom reads GitLab tokens in:

  1. CHANT_BEHAVIOUR_ENGINE_<LEXICON> for an estate whose lexicons are priced by different engines
  2. CHANT_BEHAVIOUR_ENGINE
  3. BEHAVIOUR_ENGINE

behaviourEngineFrom(lexicon, env) walks that chain and returns the address together with the variable that won, so a log line or a refusal can name it. When nothing answers, noBehaviourEngineRefusal(lexicon) builds the refusal, and renderBehaviourRefusal() prints it. It prints red rather than the amber a partial answer gets, since every behaviour figure and colour is gone rather than merely delayed.

import {
behaviourEngineFrom,
behaviourReport,
noBehaviourEngineRefusal,
unreachableBehaviourEngineRefusal,
predictedRate,
} from "@intentius/chant/behaviour";
const endpoint = behaviourEngineFrom("acme", process.env);
if (!endpoint) return noBehaviourEngineRefusal("acme");
let answer;
try {
answer = await ask(endpoint.value, graph, options.traffic);
} catch (err) {
return unreachableBehaviourEngineRefusal("acme", endpoint, String(err));
}

Resolve the engine before pricing anything. A lexicon that prices first and checks its engine afterwards has already decided what a zero means.

An engine that answers and still refuses gets its own builders, outOfCreditBehaviourEngineRefusal and overQuotaBehaviourEngineRefusal. Reach for those rather than unreachableBehaviourEngineRefusal whenever the request arrived: the address is fine in both cases, and a remedy that sends somebody to check their networking wastes the one thing a refusal is for. Better still, do not reach for any of them by hand: the transport below builds the refusal where the wire condition is seen.

BehaviourTransport is one method, send(body). It takes the request as your lexicon rendered it and brings back the engine’s text or a BehaviourRefusalReport ready to return. Two ship in chant. httpBehaviourTransport in @intentius/chant/behaviour-http POSTs to a URL with a bearer token and maps the status the way HTTP means it: 402 is engine-out-of-credit, 429 is engine-over-quota, 401 and 403 are no-engine naming the token variable, and everything else that is not a 2xx, including a thrown connection error and the timeout, is engine-unreachable. commandTransport in @intentius/chant/behaviour-engine spawns a command on PATH with behaviourEngineChildEnvironment(), which is PATH and nothing else, so an inherited environment cannot carry a credential past the request-side screen. behaviourWireRefusal is the one place a wire cause becomes a refusal, and both transports go through it.

Returning text rather than a report is deliberate. A report needs the request’s entityNames, traffic and edgeCoverage, and what the answer’s fields mean is your wire version rather than the contract’s, so a transport that built reports would be one per lexicon. One that carries bytes is one HTTP client for every lexicon there will be.

import { httpBehaviourTransport } from "@intentius/chant/behaviour-http";
const transport = httpBehaviourTransport("acme", endpoint, process.env);
const sent = await transport.send(renderRequest(graph, options.traffic));
if (!sent.ok) return sent.refusal;
const answer = parseAnswer(sent.body); // your wire version; a bad answer is unreachableBehaviourEngineRefusal

The bearer token is read from its own chain, behaviourTokenFrom(lexicon, env), in the same shape as the address chain and the same shape as gitlabNoteTokenFrom:

  1. CHANT_BEHAVIOUR_TOKEN_<LEXICON>
  2. CHANT_BEHAVIOUR_TOKEN
  3. BEHAVIOUR_TOKEN

A separate chain, because an address is printed in refusals and a token never is. The token is not the credential the rule above is about: that rule is about the request body, which screenBehaviourRequest walks before any transport exists. The token goes in the authorization header and appears in no refusal, no detail and no log line, even when the engine echoes it back into an error body. With a URL named and no token set, the HTTP transport refuses by name before sending anything, as no-engine, because the remedy is the same kind as for an unset address: set a variable, and the reason says which.

Deltas carry provenance, or say they do not

Section titled “Deltas carry provenance, or say they do not”

chant shows a declared prediction and a live prediction as a delta, and a merge-request finding posts a predicted cost delta. Provenance does not survive subtraction. Two numbers can be differenced whatever produced them, and the answer carries no trace of having crossed a basis or an engine, so a modeled figure minus a validated one reads as a change in the estate when part of it is the gap between a price list and an invoice.

compareFigures(a, b) returns the set of axes on which a pair disagrees. Every axis, not the first. Returning one label meant a pair differing in level and basis reported the level and dropped the basis crossing, so a consumer captioned the delta “different traffic level” and showed a modeled-minus-validated difference underneath with nothing said.

AxisWhen
mixed-enginethe engine, its version or its stated tolerance differs. Two models are not one scale
mixed-leveldifferent at. The same question asked of two different worlds
mixed-currencydifferent cost.currency. chant converts nothing, so USD minus EUR is not a number
mixed-basisone figure modeled off list prices, the other validated against an invoice
mixed-failuredifferent resilience.failure. Two verdicts about two events

isComparableFigure means the set is empty. FIGURE_MISMATCHES gives the display order, most fundamental first; membership is the contract and order is presentation.

Call compareFigures and not compareProvenance. The traffic level lives on the block rather than on provenance, so the provenance-only function cannot see it and will call a 100 rps figure and a 1000 rps one comparable. Within a single report the question does not arise: behaviourReport builds meta.at from the request and refuses a block that disagrees with it, so one run means one level.

The rule this contract binds consumers to is that a delta between figures classifying as anything but comparable must be marked wherever it is shown. How it is marked belongs to whatever draws it. Whether it is marked does not.

describeBehaviourConformance from @intentius/chant-test-utils runs the contract’s rules against your own mocked engine, the refusal case included. It ships with the contract rather than with the first implementation, because a rule with no runnable check is a comment.

import { describeBehaviourConformance } from "@intentius/chant-test-utils";
describeBehaviourConformance({
lexicon: "acme",
scenarios: [
{ name: "the engine is up", declared: ["web", "db"], run: () => predict(upEnv), expectPredicted: ["web"] },
{ name: "nothing names an engine", declared: ["web", "db"], run: () => predict({}), expectRefusal: true },
],
});

The canonical per-lexicon matrix, with the query mechanism and ownership channel for each, is Runtime observation coverage on the Lexicons overview page. One table, maintained in one place, so an author adding a reader has one row to update. Today’s counts.

HookLexicons
describeResources()14: aws, azure, cedar, cpln, fly, fountain, gcp, grafana, helm, k3d, k3s, k8s, render, sql
observeResourcesDeep()8: aws, azure, fountain, gcp, grafana, helm, k8s, sql
listArtifacts()2: docker, helm
exportResources()9: aws, azure, cedar, fly, fountain, gcp, grafana, k8s, sql

github, gitlab and forgejo implement none by design: workflow definitions are git-tracked, so drift is git diff. See the github and gitlab READMEs. For how the diff output is grouped, see chant lifecycle.

describeResources() returns scrubbed output metadata for diffing. It cannot regenerate a resource — attributes are cloud-assigned outputs, not the input config you wrote. Live import needs the other half: the full input config, read from the live API.

That is a separate, opt-in capability:

exportResources?(options: {
environment: string;
stack?: string; // deployed stack to export from (#932)
region?: string; // region that stack is in
selector?: ResourceSelector; // { type?, name? }
owned?: boolean; // restrict to chant-owned resources (live marker)
verbatim?: boolean; // keep server-defaulted fields; default strips
}): Promise<ExportedTemplate>;

ExportedTemplate is the existing import IR (TemplateIR) — so the result feeds your lexicon’s templateGenerator() unchanged — branded distinct from the observation types. The brand is the contract’s guardrail: a full-fidelity export (which may carry secrets) can never flow into the lifecycle code paths, which consume ResourceMetadata through the ObservationLexicon view that omits exportResources entirely.

Implementing this is what powers chant import --from <env>. A full authoring walkthrough — per-provider fidelity, the --verbatim switch, and the ownership marker — lands in the live-export authoring guide.

Live-export coverage today.

LexiconEntry pointHow it reads live config
AWSlexicons/aws/src/plugin.tsaws cloudformation get-template --template-stage Original, mapped by src/import/live-export.ts
Azurelexicons/azure/src/export-resources.tsARM GET over the applier’s transport, mapped by src/import/live-export.ts
Cedarlexicons/cedar/src/avp/live-export.tsAmazon Verified Permissions ListPolicies plus GetPolicy for statements
Flylexicons/fly/src/export-resources.tsMachines API (flaps) reads, mapped by src/import/live-export.ts
Fountainlexicons/fountain/src/export-resources.tsREST list per kind, stripped to the authored shape in the same file; a kind a selector excludes is still listed when a selected kind resolves a reference through it
GCPlexicons/gcp/src/export-resources.tsPer-kind REST GETs, mapped by src/import/live-export.ts
Grafanalexicons/grafana/src/export-resources.tsEvery dashboard over /apis/dashboard.grafana.app (or /api/search on Grafana 11) and every datasource over /api/datasources, planned by the dashboard importer in src/import/live-export.ts; secrets as key names only
K8slexicons/k8s/src/export-resources.tsTyped API client LIST per kind, stripped of status/managedFields/server metadata (kept under --verbatim), mapped by src/import/live-export.ts

Where a lexicon has both files the split is deliberate: all I/O in the entry point, the cleaning and IR-building pure in src/import/live-export.ts so it tests without a transport. cedar and fountain keep both halves in one file.

Live graph edges: referenceCatalog + enrichLiveAttrs

Section titled “Live graph edges: referenceCatalog + enrichLiveAttrs”

describeResources() gives chant graph --live its nodes — the provisioned resources. To get edges — the topology between them — a lexicon declares a reference catalog. A live resource has no declared AttrRefs; it references others by physical identifier buried in its attributes (a subnet’s VpcId, an ALB listener’s TargetGroupArn). The catalog tells chant how to reconstruct those into a graph.

referenceCatalog?: ReferenceCatalog; // { identities, refs }
  • identities — which attr paths identify each kind (its id / ARN / name). chant indexes every node by these (and its physicalId, and its own id).
  • refs — per (kind, attr path), that the value there references another resource. Each rule is tagged reference (a graph edge) or containment (subnet in VPC, a boundary box rather than a line). path supports a.b and arr[].id; targetKind disambiguates identifier collisions. A rule a fold traverses also sets viaAttr to the provider’s own attribute name (SubnetId, SecurityGroupIds), because the human-facing label is the wrong string to match on, and on a containment rule viaAttr additionally makes the relation traversable without drawing the line twice.
export const myReferenceCatalog: ReferenceCatalog = {
identities: [{ kind: "Subnet", ids: ["SubnetId"] }, { kind: "Vpc", ids: ["VpcId"] }],
refs: [{ from: "Subnet", path: "VpcId", targetKind: "Vpc", relation: "containment", label: "in VPC" }],
};

A reference whose target isn’t in the observed set is reported as dangling — never a wrong edge.

When describeResources is too thin: enrichLiveAttrs

Section titled “When describeResources is too thin: enrichLiveAttrs”

Edge reconstruction only works if the observed nodes actually carry those reference attributes. Some observation APIs return thin metadata — AWS describe-stack-resources, for instance, gives stack outputs, not per-resource references. When that’s the case, implement enrichLiveAttrs to source richer attributes for graphing:

enrichLiveAttrs?(options: {
environment: string;
stack?: string;
stacks?: Array<string | { name: string; region?: string }>;
owned?: boolean;
}): Promise<Record<string, Record<string, unknown>>>;

It returns nodeId → attributes with cross-resource references resolved to the referenced node id, which chant merges into the live nodes before reconstruction. The AWS lexicon sources these from the deployed CloudFormation template (via exportResources()), resolving {Ref} / {Fn::GetAtt} intrinsics — which reference by logical id (= the node id) — to bare strings the resolver matches. See lexicons/aws/src/reference-catalog.ts and live-attrs.ts for the reference implementation.