Skip to content

Implementing Live Export

llms.txt lists every page for an agent
Optional: hand this page to your coding agentThe steps work by hand too.
Show the whole prompt
Read https://intentius.io/chant/lexicon-authoring/live-export/.
Implement `exportResources()` and the ownership marker read for the lexicon I name: the I/O in one file, the pure mapping onto `TemplateIR` through its import parser in another, wired from `src/plugin.ts` behind a dynamic import.
Test the mapping against recorded fixtures and the read against a local emulator or a faked transport, run `npx vitest run lexicons/<name>` and `chant dev check-lexicon lexicons/<name>`, and open a pull request.
Do not run `chant import --from` against a real account or a shared environment.
Never deploy to a real cloud account or shared environment (with `chant run`, `chant components` or a platform's own CLI), approve (a pull request review, `chant approve` or `chant run approve`), override a policy denial (`chant approve policy-override`), or merge; never cut or publish a release (`chant release cut --yes` or `chant release publish`), push or delete a `ci/*` tag (`chant ci tick` pushes them), or change the value of an existing flag; never touch `.chant/allowed_signers` or `chant/lifecycle`.

Two optional capabilities let a lexicon work in the cloud → code direction: live export (regenerate TypeScript from live config) and the ownership marker (tell chant-owned resources from foreign ones). They pair with observation (the cloud → report direction) but are separate methods.

describeResources() returns scrubbed output metadata for diffing, which cannot regenerate a resource. exportResources() returns the full input config, suitable for regeneration:

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
verbatim?: boolean; // keep server-defaulted fields
}): Promise<ExportedTemplate>;

Every option maps to a flag on chant import --from <env>:

OptionFlagNotes
environment--from <env>The one required argument.
selector.type--type <ResourceType>
selector.name--name <name>
owned--owned
verbatim--verbatimDefault strips server-defaulted fields to the declared shape.
skipUndeclarable--skip-undeclarableAn export that refuses live objects no declaration can hold (throwing an error that names them) returns them as warnings instead. A thrown error is the import’s error when no other lexicon exported anything.
stacknoneCore supplies it per declared stack when the project configures stacks; a single-stack project keeps the convention of the stack named after the environment.

-d, --lexicon <name> restricts the run to one lexicon and never reaches your implementation. chant import --from warns before writing that live config may carry secrets, so it is the caller’s review step rather than yours.

The result is the existing import IR (TemplateIR), branded as ExportedTemplate, so it feeds your lexicon’s templateGenerator() unchanged. The brand keeps a full-fidelity export (which may carry secrets) from flowing into the lifecycle code paths, which consume scrubbed metadata through the ObservationLexicon view.

Keep all I/O in the activity and the mapping pure, as the shipped lexicons do:

  1. Read live objects (aws cloudformation get-template, a typed LIST, a REST GET).
  2. Strip to the declared shape by default. Remove server-written fields: status, K8s managedFields, server metadata, provider bookkeeping annotations. Keep them when verbatim is set.
  3. Map to TemplateIR by reusing your import parser.
  4. Apply the selector and the owned filter, below.

Split the two halves across two files so the mapping tests without a transport. Eight lexicons implement this today:

LexiconI/O entry point (what the plugin calls)Pure mapping
awslexicons/aws/src/plugin.ts (inline)lexicons/aws/src/import/live-export.ts
azurelexicons/azure/src/export-resources.tslexicons/azure/src/import/live-export.ts
cedarlexicons/cedar/src/avp/live-export.tssame file
flylexicons/fly/src/export-resources.tslexicons/fly/src/import/live-export.ts
fountainlexicons/fountain/src/export-resources.tssame file
gcplexicons/gcp/src/export-resources.tslexicons/gcp/src/import/live-export.ts
grafanalexicons/grafana/src/export-resources.tslexicons/grafana/src/import/live-export.ts
k8slexicons/k8s/src/export-resources.tslexicons/k8s/src/import/live-export.ts

Wire it from plugin.ts behind a dynamic import, the way every one of them does, so chant build never loads a live transport.

When your import parser hands content embedded in a resource to its owner (a collector config in a k8s ConfigMap, see Embedded content), return reparse on the export so chant import --from does the same:

const parse = (context?: ParseContext) => select(new K8sParser().parse(content, context), selector);
return { ...parse(), reparse: (context) => parse(context) };

reparse(context) parses the objects already read again with the given ParseContext. It must not read the target a second time, and it must apply the same selector and owned filters as the export. Core calls it once with a probe and, when anything is offered, once more with the owners’ importers. That is lexicons/k8s/src/import/live-export.ts. Without reparse, embedded content is kept as read.

Ownership is what later lets delete be precise without a hosted state file. The marker is stamped at synthesis time into the target’s native metadata channel:

Each lexicon exports its own ChannelKeys (a managedBy / stack / env triple). The label convention is shared as LABEL_OWNERSHIP_KEYS in @intentius/chant/ownership; tag-key syntax differs per provider, so tag-based lexicons declare their own.

ConstantWheremanagedBy key
LABEL_OWNERSHIP_KEYS@intentius/chant/ownership (k8s, gcp, grafana, helm, k3d, k3s)app.kubernetes.io/managed-by
AWS_TAG_OWNERSHIP_KEYSlexicons/aws/src/ownership.tschant:managed-by
AZURE_TAG_OWNERSHIP_KEYSlexicons/azure/src/ownership.tschant-managed-by
FLY_METADATA_OWNERSHIP_KEYSlexicons/fly/src/ownership.tsmanaged-by
CPLN_TAG_OWNERSHIP_KEYSlexicons/cpln/src/ownership.tschant.intentius.io/managed-by
RENDER_ENV_OWNERSHIP_KEYSlexicons/render/src/ownership.tsCHANT_MANAGED_BY
AVP_OWNERSHIP_KEYSlexicons/cedar/src/avp/ownership.tschant:managed-by

The value of the managed-by key is always chant (OWNERSHIP_MANAGED_BY_VALUE); the other two carry the stack name and, when the build has one, the environment.

Stamp it from your serializer by reading context.ownership and merging into your tag/label channel. The helper takes the keys, not a channel name:

import { ownershipEntries, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
serialize(entities, outputs, context) {
const labels = context?.ownership
? ownershipEntries(LABEL_OWNERSHIP_KEYS, context.ownership)
: {};
// merge `labels` into each resource's metadata.labels (explicit labels win)
}

That is lexicons/k8s/src/serializer.ts, which seeds its default-label map from the same call.

Also declare which read paths can resolve a verdict from the marker, so a caller knows the answer is available before asking:

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

chant dev check-lexicon fails a declaration naming a path the plugin does not implement, or whose keys are incomplete, and the observation conformance suite holds a declared path to a real verdict. Omit the field entirely when you have no marker channel anywhere; every verdict must then be unknown. See the observation contract.

Implement the owned filter on both describeResources and exportResources by reading the marker back:

import {
hasOwnershipMarker,
classifyOwnership,
readOwnership,
LABEL_OWNERSHIP_KEYS,
} from "@intentius/chant/ownership";
// export: drop objects without the marker
if (owned && !hasOwnershipMarker(labels, LABEL_OWNERSHIP_KEYS)) continue;
// describe: classify so the change set can gate delete
metadata.ownership = classifyOwnership(labels, LABEL_OWNERSHIP_KEYS);
// teardown: read the stack/env identity back verbatim, never inferred
metadata.marker = readOwnership(labels, LABEL_OWNERSHIP_KEYS); // undefined when unmarked

classifyOwnership returns only owned or foreign, so a path that could not read the channel must stamp "unknown" itself rather than calling it. Where tags arrive as a {Key, Value} array (CloudFormation’s form), tagArrayToMap(tags) converts to the map these expect.

chant