Implementing Observation
chant lifecycle snapshot <env> and chant lifecycle diff <env> --live query each lexicon for its view of the deployed world. Two opt-in plugin methods feed that pipeline:
| Method | Returns | Comparison axis |
|---|---|---|
describeResources() | Per-declared-entity metadata | Three-way: declared / observed-now / observed-then |
listArtifacts() | Per-environment artifact metadata | Two-way: observed-now vs. observed-then |
Both are optional. Lexicons that implement neither are warn-skipped — --live doesn’t fail. This page walks through both contracts using shipping lexicons as references.
Which contract to implement
Section titled “Which contract to implement”Pick based on the relationship between your lexicon’s chant entities and the runtime world:
| Your lexicon describes… | The runtime world is… | Use |
|---|---|---|
| 1:1 cloud resources (CFN resources, K8s objects, ARM resources, fountain environments) | Created and updated by chant build + apply | describeResources() |
| Authoring primitives (Helm charts, Compose files, CI workflow definitions) | Created by external tooling outside chant’s entity model (helm install, docker run) | listArtifacts() |
| Both | A mix of declared resources and runtime artifacts | Implement both — lifecycle diff shows them in separate sections |
| Neither (definitions-only, like git-tracked workflow YAML) | Everything is git-tracked already; drift is git diff | Implement neither; document the rationale in your lexicon README (see lexicons/github/README.md) |
The conceptual difference: describeResources() is entity-keyed — chant knows what to ask about because you declared it. listArtifacts() is context-keyed — chant doesn’t know what’s there until you look, and there’s no “declared but missing” axis to report.
describeResources()
Section titled “describeResources()”Reference implementation: lexicons/k8s/src/describe-resources.ts.
The contract:
describeResources?(options: { environment: string; buildOutput: string; entityNames: string[]; entities: Map<string, { entityType: string; props: Record<string, unknown> }>; stack?: string; // deployed stack, for a multi-stack project (`stacks` in ChantConfig) region?: string; // region that stack is deployed in (#1261) owned?: boolean; // restrict to resources carrying chant's ownership marker (#119) namespace?: string; // scope default for an entity that declares none (#1629)}): Promise<DescribeResourcesResult>; // Record<string, ResourceMetadata> | ObservationResultEvery option after entities is optional on both sides. A lexicon whose substrate has no such concept ignores it; core omits it when the project declares none. The full contract with per-field commentary is packages/core/src/lexicon.ts.
Return a map keyed by chant entity name (the export name on the *.ts file), value is the ResourceMetadata chant uses to display status and detect drift.
If your implementation has any case where it can’t look — an unmapped kind, a failed read, missing credentials — return the ObservationResult envelope instead and name those entities in unobserved. See the observation tri-state; it is the difference between chant lifecycle plan proposing a create and admitting it doesn’t know.
Start from the observer harness
Section titled “Start from the observer harness”Do not hand-roll the control flow. observeEntities() in @intentius/chant/observation owns it: bind once, read every declared entity through a bounded concurrency pool, and route each read onto the tri-state. You supply an ObserverAdapter with three members.
| Member | Signature | Job |
|---|---|---|
bind() | () => Promise<Client> | Reach the provider on the applier’s transport. Client is your own handle. A throw is a whole-lexicon failure. |
classifyBindFailure(err) | (err: unknown) => { reason: UnobservedReason; detail?: string } | "rethrow" | What a bind() throw means. Every declared entity becomes NOT-OBSERVED with that reason, or "rethrow" for a refusal core must not swallow. |
read(client, entity) | (client, entity: DeclaredEntity) => Promise<EntityObservation> | One entity. Return { present }, { absent: true }, or { unobserved: { reason, detail } }; every variant may carry queried. A throw here is caught and recorded read-failed for that entity alone. |
An optional fourth member, concurrently, supplies the transport’s own pool. Omit it and the harness uses boundedConcurrently at DEFAULT_OBSERVE_CONCURRENCY (16), so “N entities is not N serial round trips” holds whether or not your transport ships a pool.
import { observeEntities, type DeclaredEntity, type EntityObservation, type ObserverAdapter,} from "@intentius/chant/observation";
function adapter(execFn: ExecFn): ObserverAdapter<ClusterEntry[]> { return { async bind() { const { stdout } = await execFn("k3d cluster list -o json"); return JSON.parse(stdout || "[]") as ClusterEntry[]; }, classifyBindFailure(err) { // Binary missing, daemon stopped, unparsable output: all mean "could // not look", never "not there". return { reason: "read-failed", detail: err instanceof Error ? err.message.split("\n")[0] : String(err) }; }, async read(clusters, entity): Promise<EntityObservation> { if (entity.type !== CLUSTER_TYPE) return { unobserved: { reason: "unsupported-kind", detail: entity.type } }; const cluster = clusters.find((c) => c.name === declaredClusterName(entity)); if (!cluster) return { absent: true }; return { present: { type: CLUSTER_TYPE, physicalId: cluster.name, status: clusterStatus(cluster) } }; }, };}
export async function describeResources( options: { entityNames: string[]; entities: Map<string, { entityType: string; props: Record<string, unknown> }> }, execFn: ExecFn = execAsync,): Promise<DescribeResourcesResult> { const declared: DeclaredEntity[] = options.entityNames.map((name) => { const entity = options.entities.get(name); return { name, type: entity?.entityType ?? "", props: entity?.props ?? {} }; }); return observeEntities(declared, adapter(execFn));}That is lexicons/k3d/src/describe-resources.ts (chant #1412) with the ownership read elided; lexicons/k3s/src/describe-resources.ts (chant #1603) is the same adapter over a kubectl context, and lexicons/cedar/src/avp/describe-resources.ts, lexicons/cpln/src/describe-resources.ts and lexicons/fountain/src/describe-resources.ts use it over REST transports. Taking execFn (or the http client) as a defaulted second parameter is what makes the reader testable without mocking a module.
Use entity-prop pass-through to find the cloud-side identifier
Section titled “Use entity-prop pass-through to find the cloud-side identifier”entities.get(entityName).props is the literal props the user passed to the entity constructor. K8s reads props.metadata.name and props.metadata.namespace; fountain reads props.name for every resource kind; AWS uses the chant entity name directly because CloudFormation logical IDs and chant export names are 1:1.
The K8s pattern:
await client.concurrently(declared, async ({ entityName, entityType, props }) => { // entity type → apiVersion + kind, from the generated operation surface const operation = operationFor(entityType); if (!operation) { /* unobserved: unsupported-kind */ return; }
const metadata = props.metadata as { name?: string; namespace?: string } | undefined; const name = metadata?.name; if (!name) return;
const obj = await client.read({ apiVersion: operation.apiVersion, kind: operation.kind, name, ...(metadata.namespace ? { namespace: metadata.namespace } : {}), }); // ... build ResourceMetadata});Two things there are worth stealing. The entity-type -> address table is generated by the same pass that emits the declarable classes (chant #1074), rather than hand-written beside them; a hand-written one caps coverage at whatever somebody remembered to add, which is exactly how the K8s lexicon ended up unable to observe any CRD. And the reads run through a bounded concurrency pool, because a hundred declared entities should not be a hundred serial round trips.
entityNames is preserved on the options as a convenience for the simpler case where you don’t need the props (just iterate entityNames and map each to a probe call), but most non-trivial implementations want entities.
Map provider status to a meaningful string
Section titled “Map provider status to a meaningful string”ResourceMetadata.status is what lifecycle show and the diff display per resource. Different resource shapes report status differently — pick the most useful field per type, with a fallback to “PRESENT”.
K8s does this with statusFromObject():
function statusFromObject(obj: K8sObject): string { const phase = obj.status?.phase; // Pods if (typeof phase === "string") return phase; const status = obj.status as Record<string, unknown> | undefined; if (status && typeof status.readyReplicas === "number" && typeof status.replicas === "number") { return status.readyReplicas === status.replicas ? "READY" : `PROGRESSING(${status.readyReplicas}/${status.replicas})`; } return "PRESENT";}The status string is opaque to chant’s diff logic (any change is “drift”), so you can be expressive — READY, PROGRESSING(2/3), CrashLoopBackOff, ACTIVE all work fine.
observeResourcesDeep() — property-level drift
Section titled “observeResourcesDeep() — property-level drift”Reference implementation: lexicons/aws/src/deep-observe.ts.
Six lexicons implement this seam — aws, azure, gcp, k8s, helm and
fountain — each shipping a src/deep-observe.ts plus
deepNormalizationHooks, both wired through its plugin.ts. That is the seam’s
conformance evidence: six readers over six very different transports (the
Cloud Control API, an ARM GET, a direct GCP REST GET, a typed Kubernetes client,
the helm CLI delegating to that same k8s client, a fountain REST list) against
one unchanged interface, with no per-lexicon branch anywhere in
lifecycle/deep-diff.ts or deep-observation.ts. A seventh is a reader and
its hooks, not a core change.
describeResources() answers whether a declared entity exists and carries a handful of scrubbed outputs. That catches a changed status, a replaced physical id, a changed stack output — and misses the drift people actually chase: a hand-edited security-group rule, an inline policy added in the console, a flipped bucket setting, a controller-mutated CRD field. Reading the full live model is what closes that gap.
Opting in is a reader plus its normalization hooks:
observeResourcesDeep?(options: { environment: string; buildOutput: string; entityNames: string[]; entities: Map<string, { entityType: string; props: Record<string, unknown> }>; stack?: string; region?: string; owned?: boolean;}): Promise<DeepObservationResult>;
deepNormalizationHooks?: DeepNormalizationHooks; // { prune, orderKey, unresolved, mask }The result is the same tri-state contract, one level down. deepObservation(resources, unobserved) builds it; an entity whose properties could not be read gets an unobserved entry with a total reason, and throwing marks every declared entity read-failed. A deep read that fails is never allowed to arrive as a thin-but-clean tree, because a clean tree is a claim that nothing drifted.
The hard half is noise
Section titled “The hard half is noise”A raw live model is mostly fields nobody declared and nobody changed. Without pruning, a deep diff is unreadable, and an unreadable report is an ignored one. The prune hook is where a lexicon names its noise:
export const myHooks: DeepNormalizationHooks = { prune(node) { // Server-populated, wherever it appears — pruned on both sides. if (READ_ONLY_NAMES.has(lastSegment(node.pattern))) return true; // A provider default is noise only where source never declared the property. if (node.side !== "live" || node.counterpart !== "absent") return false; return DEFAULTS[node.entityType]?.[node.pattern] === node.value; }, // Which arrays are sets, and by what identity. Return undefined to leave order alone. orderKey: (el) => (lastSegment(el.pattern) === "Tags" ? String((el.element as Tag).Key) : undefined),};Two things to get right:
- The hooks are data on the plugin, not steps inside the reader. Core applies the identical rules to the declared property tree, which no reader ever touches. Bury them in the read and the two sides are normalized differently and everything is drift.
node.counterpartis a tri-state —present/absent/unknown. It says whether the same path exists on the other tree. Default subtraction is gated onabsent, so a property somebody did declare is never pruned out from under the diff;unknown(a one-sided pass, e.g. a reader normalizing its own output) prunes nothing default-related.
Two further hooks exist for the cases the pass cannot recognize on its own, both optional:
| Hook | Return true when |
|---|---|
unresolved(node) | the value cannot be known without deploying and is not a class-instance intrinsic the pass already collapses. An ARM "[resourceId(...)]" expression string is the motivating case (chant #1213); left alone it diffs against its evaluated live value forever. |
mask(node) | the value is secret material whose path is structural rather than name-shaped. A Kubernetes Secret’s data carries arbitrary key names, so isSensitiveKey cannot find it. Masking runs on both trees, so presence and key set still classify while no value reaches a diff row. |
Core owns the rest of the pass and needs no hook for it: canonical key order, hook-driven array order, secret masking by property name, and non-JSON values (an unevaluated Fn::Sub) collapsed so an interpolated property never reads as permanent drift.
The claimed-field set: half of that noise has a general answer
Section titled “The claimed-field set: half of that noise has a general answer”Three of the four hooks exist because the live tree carries fields the declaration never mentions, and every lexicon has been answering that with knowledge it wrote down by hand. There is a source of that knowledge nobody had to write: the declaration itself. A ResourceDeclarable’s props are exactly the fields chant set, they exist on every substrate, and they come from the same build that produced the entity.
Core derives them per entity, normalized with your hooks and flattened into the diff’s own path grammar, and classifies each live value three ways instead of two:
| Classification | Result | Consequence |
|---|---|---|
| declared and equal | unchanged | Nothing to report. |
| declared and different | drifted | Drift, and the only case that may become an update. |
| undeclared | unclaimed | Reported with its live value, never drift, never proposed for change. |
Nothing in a lexicon implements this, the same way nothing implements the accepted baseline: it is applied by core, above your reader. What it changes for you is what the prune hook is for. Pruning is still right for a field the platform populates on every object of a kind (status, an ARN, a generation counter) and for putting two vocabularies in the same shape. It is no longer the only way to keep a foreign controller’s field out of a drift report, so a table entry that exists only to say “nobody declared this” now has a general mechanism underneath it and can go when you trust the classification. Do not remove one blind: verify against a real estate first, because a prune entry and an unclaimed field are reported differently. Pruned means the field is not in the tree at all; unclaimed means it is reported with its value.
Two things worth designing for:
orderKeysharpens the claim. A path inside a list is claimed at its exact position or key, so a set-like list your hook can key (Tags[#env].Value) keeps a property’s identity still while the set moves around it, and a console-added tag classifies as its own path. A positional list claimsTags[0]and reports a new element by index.- Where the substrate names the writer, keep supplying it.
DeepResourceObservation.fieldOwnersstill wins over the claim for a held field: “held byhpa-controller” is strictly more than “chant did not set it”, and core falls back to the claim only where your reader has no manager for the path. That is Kubernetes today, viametadata.managedFields, and it is what the two mechanisms composing looks like.
The claim is also available directly, for a consumer that has a declaration and no diff:
import { claimedFieldsOfProps, isClaimed } from "@intentius/chant/claimed-fields";
const claimed = claimedFieldsOfProps(entity.props, { entityType: entity.entityType, hooks: myHooks });isClaimed(claimed, "spec.replicas"); // did this declaration ever set itclassifyDisruption() — what applying an update costs
Section titled “classifyDisruption() — what applying an update costs”Reference implementation: lexicons/aws/src/disruption.ts. lexicons/fountain/src/disruption.ts is the same hook where no registry schema exists to read: the table is written from the provider’s routes, naming which fields a PATCH accepts and which ones are the resource’s identity, and it covers three of the six kinds and says unknown for the rest.
lifecycle plan reports what changes. What it costs to apply is a separate question, and only the lexicon can answer it: replacement semantics live in the spec you already compile (CloudFormation’s createOnlyProperties, Kubernetes’ SSA schema), and the attribute paths on a change-set entry are paths into your observation shape, which core cannot map back onto property names.
classifyDisruption?(options: { environment: string; changes: DisruptionQuery[]; // { name, type?, deltas }}): Record<string, DisruptionVerdict>; // { disruption, because?, detail? }Called once per lexicon with that lexicon’s update entries, before the plan merges the change sets. Expect a table lookup over compiled spec data — no live API call, no mutation. A Promise is allowed for a lexicon whose answer needs an await.
The vocabulary is in-place / rolling / replace / destroy / unknown, described in lifecycle plan.
Return a partial map. A name you say nothing about is unknown, which is the right answer wherever the spec does not say — a conditionally-immutable property, a type with no schema on record. Core also forces unknown when the method is absent, when it throws, and when it returns a level outside the vocabulary, so there is no path by which a lexicon accidentally emits a confident in-place. That is what makes in-place worth gating on: it is only ever a deliberate claim, backed by a detail naming the spec knowledge behind it.
describeIdentity() — who chant would act as
Section titled “describeIdentity() — who chant would act as”Reference implementations: lexicons/aws/src/caller-identity.ts, lexicons/k8s/src/whoami.ts.
Your describeResources() already resolves an identity and a scope before it reads anything — the bind reaches the provider on the applier’s transport, the connector picks a context, the read client picks a region and an endpoint — and then throws both away. chant lifecycle whoami <env> reports them, one row per lexicon, before anything acts.
describeIdentity?(options: { environment: string; region?: string; // the region the env's stacks declare, when they agree on one cwd?: string; // project root whose chant.config.ts carries the binding}): Promise< | { identity: string; scope: string; source: string; endpoint?: string } | { unresolved: { reason: UnobservedReason; detail?: string } }>;identity— the principal, as the substrate names it. An STS ARN, asystem:serviceaccount:<ns>:<name>subject, a service-account email. Not what your config calls it: a kubeconfiguserentry is a local alias, and the whole reason the question is worth asking is that the substrate may map that credential onto a different subject.scope— what the principal is scoped to here. An account plus region, a cluster context plus namespace, a project, an org. This is the half where a wrong-account read is actually visible.source— where the binding came from, in the project’s own vocabulary:k8s.profiles.prod.context,stacks[].region,env AWS_ACCESS_KEY_ID. An identity with no provenance cannot be corrected by whoever reads it.endpoint— the address the self-query went to.
Three rules
Section titled “Three rules”Read-only. whoami is pre-flight and never a gate. The self-query must be one the substrate treats as a read of the caller’s own identity. Kubernetes’ SelfSubjectReview is a POST and still qualifies: the server evaluates the request’s own credentials and returns the result without persisting anything, which is exactly what kubectl auth whoami does.
Never return a credential. An identity is a principal and a scope, never a token, key, password or certificate. Where a substrate’s only identity signal is a secret, report the fact without the value — the k8s implementation reports credential token and stops there. Core scrubs credential-shaped material from every field as a backstop; that is not a reason to put one there.
The endpoint must be the one your read uses. Resolve it through the same helper describeResources() resolves it through, so an endpoint override, a region and a context binding cannot diverge between the two. A whoami that names a target the read does not reach is worse than no whoami, so pin it with a test: the aws suite asserts both calls land on the same origin under AWS_ENDPOINT_URL, and the k8s suite asserts the reported server is the one the declared-entity reads go to.
Unresolved is a real answer
Section titled “Unresolved is a real answer”Return { unresolved: { reason, detail } } whenever you cannot resolve an identity, with a reason from the same UnobservedReason set:
| Reason | Says |
|---|---|
no-credentials | Nothing is configured to act as, or the substrate refused the self-query. |
no-binding | This environment resolves to no concrete target. |
read-failed | The substrate was reached and the self-query errored — or serves no self-query at all. |
no-credentials and read-failed are different claims and render differently, so do not collapse them. Never invent an identity to fill a row: a lexicon that returns an empty principal is forced to unresolved by core, and a throw degrades to read-failed for that lexicon alone without failing the command. Omitting the method entirely reports not reported, which is honest — it is never rendered as an empty identity.
listArtifacts()
Section titled “listArtifacts()”Reference implementation: lexicons/helm/src/list-artifacts.ts.
The contract:
listArtifacts?(options: { environment: string; entities: Map<string, { entityType: string; props: Record<string, unknown> }>; stack?: string; // scope enumeration to one deployed stack, for a multi-stack project}): Promise<Record<string, ArtifactMetadata>>;Return a map keyed by your artifact identifier (whatever uniquely identifies the runtime thing — e.g. release/<namespace>/<name> for Helm, container/<name> for Docker), value is ArtifactMetadata (same shape as ResourceMetadata).
Pick a stable, namespaced key
Section titled “Pick a stable, namespaced key”The diff engine compares per-key. Two-snapshot stability matters more than aesthetics. Helm uses release/<namespace>/<name>, Docker uses container/<name>, image/<repo>:<tag>, network/<name>. Type prefix in the key makes the diff output legible (release/..., container/... are easy to read in the same section).
Daemon / binary missing -> return {} cleanly
Section titled “Daemon / binary missing -> return {} cleanly”If the tool you’re shelling out to isn’t installed or unreachable, return an empty map. Don’t fail the whole snapshot — other lexicons should still run:
try { ({ stdout } = await execAsync("helm list -A -o json"));} catch { // Binary not installed, no kubeconfig, or some other error — return // empty rather than blocking the whole snapshot. return result;}Per-source failure isolation
Section titled “Per-source failure isolation”Docker queries three independent surfaces (containers, images, networks). One failure shouldn’t stop the others. Run them in parallel, each with its own try/catch, and merge:
const [containers, images, networks] = await Promise.all([ listContainers(), listImages(), listNetworks(),]);return { ...containers, ...images, ...networks };Per-tenant query when the runtime is multi-tenant
Section titled “Per-tenant query when the runtime is multi-tenant”Some runtimes are partitioned per tenant — per database, per cluster, per region — rather than exposing one global list. When that’s the case, discover the declared entities that name each tenant from the entities map and query each one independently:
const tenants: string[] = [];for (const [, { entityType, props }] of options.entities) { if (entityType !== "MyLexicon::Tenant") continue; const name = props.name as string | undefined; if (name) tenants.push(name);}
for (const tenant of tenants) { try { const { stdout } = await execAsync(`mytool info --target=${tenant} --output=json`); // ... merge artifacts keyed by `tenant/<id>` } catch (err) { console.warn(`[my-lexicon] failed to query "${tenant}": ${err}`); continue; // other tenants still proceed }}Per-tenant warn-soft is the right default: a broken connection on staging shouldn’t block the prod artifacts from being recorded.
Testing
Section titled “Testing”Both contracts are pure async functions of their options, so a test needs no cluster and no credentials. Two patterns ship, matched to how the reader reaches its provider.
An injected transport. Take the exec function or http client as a defaulted parameter and hand the test a fake. No module mocking, no vi.mock ordering hazard. lexicons/k3d/src/describe-resources.test.ts:
import { describeResources, type ExecFn } from "./describe-resources";
function fakeExec(handlers: Record<string, string | Error>): ExecFn { return async (command: string) => { for (const [prefix, result] of Object.entries(handlers)) { if (command.startsWith(prefix)) { if (result instanceof Error) throw result; return { stdout: result }; } } throw new Error(`unexpected command: ${command}`); };}
const result = await describeResources(options(), fakeExec({ "k3d cluster list": RUNNING_CLUSTER }));A faked cluster or API. Where the transport is a typed client rather than a subprocess, drive the real client against a literal config with the wire faked. lexicons/k8s/src/describe-resources.test.ts builds one with fakeCluster() and fakeKubeconfig() from @intentius/chant-k8s-client/testing, so no ambient kubeconfig is read and no cluster is contacted.
Either way, assert on the normalized result rather than the raw return: normalizeObservation(result) from @intentius/chant/observation gives you resources and unobserved whichever shape the reader chose.
Mocking the plugin contract end-to-end
Section titled “Mocking the plugin contract end-to-end”For tests that exercise consumers of the contract (the lifecycle diff engine, a custom Op activity), @intentius/chant-test-utils ships a factory plus four static backings:
| Helper | What it gives you |
|---|---|
createMockPlugin(options) | A minimal plugin satisfying LexiconPlugin. options accepts name, serializer, and any of describeResources, listArtifacts, observeResourcesDeep, observeDependencies, teardownOwned, executeTeardown. |
staticDescribeResources(record) | A describeResources returning that bare name -> ResourceMetadata map. |
staticObservation(resources, unobserved) | A describeResources returning the versioned envelope, for a test that needs an unobserved row. |
staticDeepObservation(resources, unobserved) | The same for observeResourcesDeep. |
staticListArtifacts(record) | A listArtifacts returning that artifact map. |
import { createMockPlugin, staticDescribeResources } from "@intentius/chant-test-utils";
const plugin = createMockPlugin({ name: "fake-cloud", describeResources: staticDescribeResources({ web: { type: "Fake::Service", status: "READY" }, }),});Use the static factories to keep diff-engine and Op-activity tests free of child_process mocks they don’t need.
See also
Section titled “See also”chant graph --live— the provisioned graph these hooks feedchant lifecycle— user-facing CLI reference- Watching Lifecycle — how observation feeds into continuous drift detection via
WatchOp - Completeness Checklist — full list of what a production-ready lexicon implements
- Observation Contract — the result shape behind these hooks: the tri-state, ownership verdicts, coverage, and the additional opt-in readers