Skip to content

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:

MethodReturnsComparison axis
describeResources()Per-declared-entity metadataThree-way: declared / observed-now / observed-then
listArtifacts()Per-environment artifact metadataTwo-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.

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, Temporal namespaces)Created and updated by chant build + applydescribeResources()
Authoring primitives (Helm charts, Compose files, CI workflow definitions)Created by external tooling outside chant’s entity model (helm install, docker run)listArtifacts()
BothA mix of declared resources and runtime artifactsImplement both — lifecycle diff shows them in separate sections
Neither (definitions-only, like git-tracked workflow YAML)Everything is git-tracked already; drift is git diffImplement 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.

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> }>;
}): Promise<Record<string, ResourceMetadata>>;

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.

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; Temporal reads props.name for namespaces and props.scheduleId for schedules; AWS uses the chant entity name directly because CloudFormation logical IDs and chant export names are 1:1.

The K8s pattern:

for (const [entityName, { entityType, props }] of options.entities) {
const kubectlResource = KUBECTL_RESOURCE[entityType];
if (!kubectlResource) {
skippedTypes.add(entityType);
continue;
}
const metadata = props.metadata as { name?: string; namespace?: string } | undefined;
const name = metadata?.name;
if (!name) continue;
const cmd = ["kubectl", "get", kubectlResource, name,
...(metadata.namespace ? ["-n", metadata.namespace] : []),
"-o", "json"].join(" ");
// ... query and build ResourceMetadata
}

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 statusFromKubectl():

function statusFromKubectl(obj: KubectlResponse): 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.

Resource-not-found is expected during diff — it’s how lifecycle diff --live reports a MISSING (declared, not in cloud) entry. Catch the per-resource error and continue:

try {
const { stdout } = await execAsync(cmd);
const obj = JSON.parse(stdout);
result[entityName] = { type: entityType, /* ... */ };
} catch {
// Resource not found / kubectl error — leave it out so lifecycle diff
// can report it as missing. Don't fail the whole snapshot.
}

Failing the whole snapshot because one Deployment is gone defeats the point.

Lexicons grow new resource types over time. If your describe path doesn’t cover a type yet, accumulate them and warn once — don’t error:

const skippedTypes = new Set<string>();
// ... in the loop, skippedTypes.add(entityType) when unmapped
if (skippedTypes.size > 0) {
console.warn(`[k8s] skipped ${skippedTypes.size} entity type(s) without kubectl mapping: ${[...skippedTypes].join(", ")}`);
}

The user gets actionable feedback (PR-able list of types to add); the snapshot proceeds with what’s covered.

Reference implementation: lexicons/helm/src/list-artifacts.ts.

The contract:

listArtifacts?(options: {
environment: string;
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
}): 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).

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 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;
}

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.

Both contracts are pure async functions of their options — easy to unit-test by mocking child_process.exec or whatever client your lexicon uses.

Pattern from lexicons/k8s/src/describe-resources.test.ts:

import { describe, test, expect, vi, beforeEach } from "vitest";
const execMock = vi.fn();
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return { ...actual, exec: (cmd, cb) => {
Promise.resolve(execMock(cmd)).then(
(out) => cb(null, out),
(err) => cb(err, { stdout: "", stderr: "" }),
);
}};
});
const { describeResources } = await import("./describe-resources");

Then drive execMock per command pattern and assert on the returned ResourceMetadata shape.

For tests that exercise consumers of the contract (the lifecycle diff engine, a custom Op activity), @intentius/chant-test-utils ships:

  • createMockPlugin({ name, describeResources?, listArtifacts? }) — minimal plugin satisfying the type
  • staticDescribeResources(record) / staticListArtifacts(record) — closure-returning factories for tests that don’t care about the query mechanics, just the data flow
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.

LexicondescribeResources()listArtifacts()How it queries
AWSaws cloudformation describe-stack-resources
Azureaz resource show per declared entity
GCP (Config Connector)kubectl get <gvk> <name> -o json against the GKE cluster
K8skubectl get <kind> <name> [-n <ns>] -o json per declared entity
TemporalTemporal client (workflowService.listNamespaces, operatorService.listSearchAttributes, scheduleClient.list)
Helmhelm list -A -o json
Dockerdocker ps, docker image ls, docker network ls (NDJSON)
GitHub / GitLabN/A — git-tracked authoring primitives, see github and gitlab READMEs

For the user-facing version of this matrix and 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;
selector?: ResourceSelector; // { type?, name? }
owned?: boolean; // inert until ownership marking lands
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 state 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:

LexiconexportResources()How it reads live config
AWSaws cloudformation get-template --template-stage Original, parsed by the CloudFormation import parser
K8skubectl get <kinds> -A -o json, stripped of status/managedFields/server metadata (kept under --verbatim), parsed by the K8s import parser
GCP (Config Connector)kubectl get <*.cnrm.cloud.google.com> -A -o json (each CC object is its manifest), stripped of status/server metadata/cnrm.cloud.google.com/* annotations, parsed by the GCP import parser