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.
describeResources()
Section titled “describeResources()”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:
| Verdict | How you report it | What chant does with it |
|---|---|---|
| Observed present | a key in resources | drift comparison, noop / update |
| Observed absent | in neither map — you asked, the provider said no | MISSING in the diff, create in the plan |
| Not observed | a key in unobserved, with a reason | reported 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:
| Reason | When |
|---|---|
read-failed | the provider was reached and the read errored |
no-credentials | no usable credentials for the target |
no-binding | the environment resolves to no concrete target (no kubectl context, no subscription, no stack) |
unsupported-kind | your lexicon has no reader for this entity type |
filtered | the 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 visibleconst 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.
queriednever changes a verdict; classification reads onlyresourcesandunobserved, and the tri-state above does not shift. An entity inqueriedand 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
queriedmap 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.
Ownership verdicts are total
Section titled “Ownership verdicts are total”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.
Declare where you can read the marker
Section titled “Declare where you can read the marker”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):
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.
| Lexicon | Keys | Resolves on |
|---|---|---|
| aws | chant:managed-by tags | all three (describeResources at stack granularity) |
| azure | chant-managed-by tags | exportResources |
| cedar | chant:managed-by AVP tags | describeResources, exportResources |
| cpln | chant.intentius.io/managed-by tags | describeResources |
| fly | managed-by machine metadata | describeResources |
| gcp | app.kubernetes.io/managed-by labels | all three |
| grafana | app.kubernetes.io/managed-by labels on a dashboard.grafana.app resource, or the grafana.app/managerId annotation naming a project provider | all three, for dashboards over /apis; datasources and Grafana 11 dashboards say unknown |
| helm | app.kubernetes.io/managed-by labels | describeResources, observeResourcesDeep |
| k3d | app.kubernetes.io/managed-by labels | describeResources |
| k3s | app.kubernetes.io/managed-by labels | describeResources |
| k8s | app.kubernetes.io/managed-by labels | all three |
| render | CHANT_MANAGED_BY env vars | describeResources |
| sql | a [chant managed-by=chant stack=… env=…] trailer on the object’s COMMENT, after the declared comment | describeResources, 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.
Prove it with the conformance suite
Section titled “Prove it with the conformance suite”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.
| Field | Meaning |
|---|---|
name | Test title. |
declared | Entity names the lexicon was asked about, the declared axis fed to buildChangeSet. |
run | Invokes your describeResources under your own transport fake. Returns a DescribeResourcesResult. |
expectPresent | Names that must land in resources. |
expectAbsent | Names that must be in neither map, and that must classify as create. |
expectUnobserved | Names that must land in unobserved, and that must classify as unobserved rather than create. |
owned | This scenario ran with owned: true, so hold the lexicon to its declared channel. |
expectMarker | Per entity, the exact ResourceMetadata.marker (stack + env) the read must surface. |
expectNoMarker | Names 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.
| Classification | Result |
|---|---|
| declared and equal | Nothing to report. |
| declared and different | Drift. The only case that may become an update. |
| undeclared | Reported 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.
The accepted baseline
Section titled “The accepted baseline”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.
Beyond the declared estate
Section titled “Beyond the declared estate”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.
| Reader | Implemented 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.
listArtifacts()
Section titled “listArtifacts()”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.
predictBehaviour()
Section titled “predictBehaviour()”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
Authorizationvalue, 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/prodandnew 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.
A prediction is never a bill
Section titled “A prediction is never a bill”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 literalrate: "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. provenanceis required on every entity, and itsbasisis a closed enum ofmodeled(computed off list prices) orvalidated(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.
The behaviour tri-state
Section titled “The behaviour tri-state”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.
| Verdict | How you report it | What it means |
|---|---|---|
| Predicted | a key in entities | the engine models this kind and produced figures for it |
| Not predictable for this kind | unpredicted, with reason unsupported-kind | the 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 predicted | unpredicted, with another reason | the 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.
| Reason | When |
|---|---|
read-failed | the engine was reached and the prediction errored |
no-binding | the environment resolves to no concrete target to predict |
unsupported-kind | the engine has no model for this entity type |
filtered | reached but withheld by owned: true |
no-engine | no variable in the chain names an engine, or the transport needs a token and none is set or the engine rejected it |
engine-unreachable | a variable named an engine and it did not answer |
engine-out-of-credit | the engine answered and refused: the account has no balance |
engine-over-quota | the engine answered and refused: a rate or volume limit is spent |
A missing engine refuses by name
Section titled “A missing engine refuses by name”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:
CHANT_BEHAVIOUR_ENGINE_<LEXICON>for an estate whose lexicons are priced by different enginesCHANT_BEHAVIOUR_ENGINEBEHAVIOUR_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.
The transport is the contract’s
Section titled “The transport is the contract’s”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 unreachableBehaviourEngineRefusalThe 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:
CHANT_BEHAVIOUR_TOKEN_<LEXICON>CHANT_BEHAVIOUR_TOKENBEHAVIOUR_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.
| Axis | When |
|---|---|
mixed-engine | the engine, its version or its stated tolerance differs. Two models are not one scale |
mixed-level | different at. The same question asked of two different worlds |
mixed-currency | different cost.currency. chant converts nothing, so USD minus EUR is not a number |
mixed-basis | one figure modeled off list prices, the other validated against an invoice |
mixed-failure | different 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.
Prove it with the conformance suite
Section titled “Prove it with the conformance suite”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 }, ],});Coverage today
Section titled “Coverage today”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.
| Hook | Lexicons |
|---|---|
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.
Live export — exportResources()
Section titled “Live export — exportResources()”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.
| Lexicon | Entry point | How it reads live config |
|---|---|---|
| AWS | lexicons/aws/src/plugin.ts | aws cloudformation get-template --template-stage Original, mapped by src/import/live-export.ts |
| Azure | lexicons/azure/src/export-resources.ts | ARM GET over the applier’s transport, mapped by src/import/live-export.ts |
| Cedar | lexicons/cedar/src/avp/live-export.ts | Amazon Verified Permissions ListPolicies plus GetPolicy for statements |
| Fly | lexicons/fly/src/export-resources.ts | Machines API (flaps) reads, mapped by src/import/live-export.ts |
| Fountain | lexicons/fountain/src/export-resources.ts | REST 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 |
| GCP | lexicons/gcp/src/export-resources.ts | Per-kind REST GETs, mapped by src/import/live-export.ts |
| Grafana | lexicons/grafana/src/export-resources.ts | Every 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 |
| K8s | lexicons/k8s/src/export-resources.ts | Typed 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 itsphysicalId, and its own id).refs— per(kind, attr path), that the value there references another resource. Each rule is taggedreference(a graph edge) orcontainment(subnet in VPC, a boundary box rather than a line).pathsupportsa.bandarr[].id;targetKinddisambiguates identifier collisions. A rule a fold traverses also setsviaAttrto the provider’s own attribute name (SubnetId,SecurityGroupIds), because the human-facinglabelis the wrong string to match on, and on a containment ruleviaAttradditionally 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.
See also
Section titled “See also”- Implementing Observation — the implementation walkthrough for these hooks