Skip to content

chant search

llms.txt lists every page for an agent
chant search "<query>" [--live | --snapshot <ref>] [--env <name>] [flags]
chant search "<query>" --index <scope key>[@<version>] [flags]
chant search ["<query>"] --count-by <kind | module | attr:<name>> [flags]

chant search answers a question about your estate with a compact result instead of the whole graph. Each match prints as one line — logical id, kind, physical id when known, and any attributes you asked for. A result is tens of tokens.

An agent asking “which instances sit in public subnets” runs one query and reads a few rows, instead of joining instance -> subnet -> route table by hand across a multi-thousand-token IR dump. chant search is the query the agent integration uses, and it is what kept chant’s token footprint the lowest of any tooling configuration on aws-bench, an infrastructure-agent benchmark.

By default the query runs over the declared graph (source only, offline). That graph holds what a lexicon reads from outside the TypeScript source too, the same set chant graph draws: on a Terraform project, every block of every configured root. With --live --env <name> chant observes the environment, overlays live identity and attributes onto the declared graph, and answers from that, on the same canvas as chant graph --overlay.

The declared graph is the one chant graph --format ir draws: the discovered source, plus what a lexicon renders from a build root (each terraform.roots root’s blocks, each k8s.kustomize.roots root’s objects), plus the edges a lexicon resolves itself. A node’s id in a search row is its id in the graph IR, so net/aws_security_group.web names the same node in both. A build root that fails to render fails the search with that error and exit code 1, since an answer over the rest of the graph would say (no matches) about a root it never read.

Space-separated terms. All terms must match (AND). Quote phrases to keep them together.

TermMatches
wordcase-insensitive substring over id, kind, and attribute values
kind:<substr>node kind contains <substr> (e.g. kind:EC2::Instance); on a Terraform resource, its type (kind:aws_instance, see below)
attr:<name>attribute <name> is present
attr:<name>=<val>attribute <name> equals/contains <val>
tag:<key>=<val>a Tags entry with Key=<key> and Value containing <val>
id:<cloud id>the node’s observed physical id, or one of its engine facts’ cloud ids, is exactly <cloud id> (id:i-0abc123, id:arn:aws:s3:::my-bucket). See Cloud ids
->kind:X, ->attr:…, ->tag:…node has an edge to a node matching the right side
<-kind:X, <-attr:…, <-tag:…node has an edge from a node matching the right side
->*X, <-*Xthe far end matching X is any number of hops away, one or more
->{n}X, <-{n}Xexactly n hops away
->{m,n}X, <-{m,n}Xbetween m and n hops away, inclusive ({m,} is m or more)
->[via=<attr>]X, <-[via=<attr>]Xonly edges whose consumer attribute (viaAttr) is <attr>
->[to=<attr>]X, <-[to=<attr>]Xonly edges that read the producer attribute (toAttr) <attr>
->[via=<attr>], <-*[to=<attr>]a qualified edge may omit the target: any node at the far end
cap:<name>, cap:<name>(<args>)the node is in a capability’s set, or is the source of a capability edge
->[cap=<name>]X, <-[cap=<name>]Xwalk a capability edge instead of the graph’s own edges (->[cap=reaches]cap:stores-data)
!<term>the node does not satisfy the term

A bare word does not search a Terraform node’s source attribute. That attribute holds the whole .tf file the block came from, so a bare aws_security_group would match every block in a file that mentions one. The block’s address and body are still searched, so a block that references a group still matches. To search the file text, ask for it: attr:source=aws_security_group.

The edge operators make the search edge-aware: a traversal becomes one term instead of a hand-join across results.

Terminal window
# instances that reference a public subnet
chant search "kind:Instance ->attr:MapPublicIpOnLaunch=true"

An edge points from the resource that holds a reference to the resource it names: aws_subnet.a with vpc_id = aws_vpc.main.id has an edge to aws_vpc.main through vpc_id (its viaAttr) reading id (its toAttr). So -> walks toward what a resource reads, and <- walks toward what reads it.

The parts of an edge term go in this order: arrow, depth, qualifier, target. A plain -> or <- is one hop. A qualifier holds for every edge on the path, not only the first one, and names match ignoring case. An edge the lexicon recorded no attribute for never satisfies a qualifier. A cycle in the graph does not stop the walk from ending.

Terminal window
# everything that reads aws_vpc.main, directly or through other resources
chant search "<-*aws_vpc.main" --explain
# resources within two hops of an IAM role
chant search "->{1,2}kind:aws_iam_role"
# VPCs some resource reads through vpc_id
chant search "kind:aws_vpc <-[via=vpc_id]"

Every Terraform resource block is Terraform::Resource in the graph, and carries its two labels as attrs.type and attrs.name. kind: reads the type as well as the kind, with the same boundary rule kind:EC2::VPC follows on ::, applied to _:

TermMatches
kind:aws_instancethat type exactly
kind:aws_iam_rolethe roles, and never aws_iam_role_policy or aws_iam_role_policy_attachment, even on a root that has no aws_iam_role at all
kind:aws_iam_every IAM type: a term ending in _ is a prefix
kind:awsevery aws_ type, since a bare provider name is the same prefix
kind:Terraform::Resource, kind:Resourceevery resource, as before
kind:data.aws_amithe AMI lookups: a data block’s type keeps its prefix, and kind:aws_ami matches no data source

A result row shows the type where a CloudFormation row shows its kind, and --explain counts against it (“2 of 2 aws_instance matched”). These are the same types --count-by kind groups by, so kind:<group> returns exactly the rows counted in that group. attr:type= is a substring test like any other attr:, so prefer kind: for a type.

Terminal window
chant search "kind:aws_iam_role"
chant search "kind:aws_iam_ !kind:aws_iam_role" # IAM types other than roles

Before matching, chant folds derived reachability facts onto instance nodes (effective-topology enrichment), so multi-hop and launch-template joins become one node predicate:

  • internetFacing — the instance’s subnet routes to an internet gateway (resolved across the account default VPC in live mode)
  • effectiveIngress — normalized ingress rules from all reachable security groups, attached directly or via the instance’s launch template
Terminal window
chant search "kind:EC2::Instance attr:internetFacing=true" --live --env dev
chant search "kind:EC2::Instance attr:internetFacing=true attr:effectiveIngress=tcp:22:0.0.0.0/0" --live --env dev --explain --show InstanceId

The same two facts are folded onto a Terraform root’s aws_instance blocks, read from terraform.roots in chant.config.ts:

  • effectiveIngress follows vpc_security_group_ids, security_groups and launch_template (to the template’s vpc_security_group_ids), and reads inline ingress blocks, aws_security_group_rule resources with type = "ingress" and aws_vpc_security_group_ingress_rule resources.
  • internetFacing follows subnet_id and aws_route_table_association to a route table whose default route goes to an aws_internet_gateway, written either as an inline route block or as an aws_route resource. A subnet on the VPC’s main route table (no association) is not followed.

Without --live these come from declared references alone, and the provenance line says so:

$ chant search "attr:internetFacing=true attr:effectiveIngress=tcp:22:0.0.0.0/0" --explain
net/aws_instance.web aws_instance
— declared only · no observation · physical ids unavailable · effectiveIngress, internetFacing derived from declared references
— 1 of 3 aws_instance matched (query: attr:internetFacing=true attr:effectiveIngress=tcp:22:0.0.0.0/0)
✓ net/aws_instance.web internetFacing via subnet route aws_subnet.public_a -> aws_route_table.public_inline -> aws_internet_gateway.main (inline route)
✓ net/aws_instance.web effectiveIngress via security groups aws_launch_template.web -> aws_security_group.web
· excluded net/aws_instance.batch — fails attr:effectiveIngress=tcp:22:0.0.0.0/0
· excluded net/aws_instance.db — fails attr:internetFacing=true

A capability is a named fact the graph can prove, such as “this instance can be reached on SSH from the internet” or “this role can reach that table”. Each is a rule in a small path-pattern language over chant’s graph, shipped as data (#4375, ruled on #4361). The capabilities page lists them and describes the language.

A capability with one variable is a set, and cap:<name> tests membership. A capability with two is an edge, and ->[cap=<name>] walks it. Arguments go in parentheses: cap:open-ingress(22), ->[cap=grants(s3:GetObject)]. An argument left out drops the test that would read it.

Terminal window
# what can be reached on SSH from the internet
chant search "cap:internet-reachable(22)" --explain
# if this role is compromised, what data can it reach
chant search "cap:stores-data <-[cap=reaches]aws_iam_role.app_data"
# internet-facing hosts that can read restricted data
chant search "cap:exfiltrates" --explain

One rule file serves CloudFormation and Terraform’s AWS provider: each lexicon maps its kinds onto the labels the rules name. chant search --capabilities prints the catalog. A capability the query names that doesn’t exist, a set used as an edge, or arguments a capability doesn’t take are refused with exit code 1, never answered with (no matches).

Every answer that names a capability says where its facts came from on the provenance line, and what the capability does not evaluate on a line of its own, with a count where the graph holds that thing:

$ chant search "cap:stores-data <-[cap=reaches]aws_iam_role.app_data"
findings/aws_dynamodb_table.sessions aws_dynamodb_table
— declared only · no observation · physical ids unavailable · stores-data, reaches derived from declared references
— reaches does not evaluate: IAM Condition keys; Resource "*" (2 statements here); NotAction and NotResource; permission boundaries and SCPs; AWS-managed policies, named by ARN only; resources the graph holds but can't resolve, such as an expression naming nothing declared

With --explain, each match prints the path that proved it, one hop per edge, in the hop format below. A hop that leaves the graph’s own nodes names the node inside a resource it reached: a policy statement (aws_iam_policy.app_data#policy[0].Statement[0]) or an inline block (aws_route_table.public#route[0]). The sets a match relied on follow, each with its own path:

path findings/aws_instance.bastion cap:internet-reachable(22): (the node itself)
internet-facing findings/aws_instance.bastion: findings/aws_instance.bastion.subnet_id -> findings/aws_subnet.public.id; findings/aws_subnet.public.id <- findings/aws_route_table_association.public.subnet_id; ...
open-ingress findings/aws_instance.bastion: findings/aws_instance.bastion.launch_template -> findings/aws_launch_template.bastion.id; ...
FlagTypeDefaultDescription
--livebooleanfalseQuery live state overlaid on the declared graph (requires --env)
--snapshotstring—Answer from a recorded snapshot instead of reading the estate now (latest; requires --env; mutually exclusive with --live)
--atstring—The same as --snapshot, kept for compatibility. Elsewhere in the read contract --at is a git revision, so chant workspace search takes only --snapshot
--indexstring—Answer from an index version in the store instead of reading the estate: <scope key>, <scope key>@latest or <scope key>@<version> (see Index mode). Mutually exclusive with --live, --snapshot and --ambient
--envstring—Environment to observe (must be in chant.config environments when declared)
--srcstringconfig sourceDir or .Source directory to discover/build
--showstring—Comma-separated attribute names to append to each result row
--count-bystring—Print one count per group instead of one row per match: kind, module or attr:<name> (see Grouped counts)
--explainbooleanfalsePrint the match-universe footer, each match’s path for edge terms, and near-miss exclusions
--capabilitiesbooleanfalsePrint the capability catalog instead of answering a query; with --json, the capabilities document. Takes no query and no other search flag
--jsonbooleanfalsePrint the whole answer (rows, paths, provenance, --explain, drift, failures) as the read contract’s search document instead of rows and footers
--ambientbooleanfalseAlso report resources of a managed kind that exist without being declared or referenced
--check-livebooleanfalseWith --snapshot (or --at): additionally read the estate live and diff the matched rows against the snapshot the answer came from
--check-snapshotbooleanfalseWith --live: additionally diff the matched rows against the most recently recorded snapshot
--fail-on-driftbooleanfalseWith --check-live/--check-snapshot: exit non-zero when the scoped check finds drift

--count-by answers “how many of each” in one call. Instead of a row per match it prints one line per group with its count, sorted by count (highest first) and then by name, and a total line:

$ chant search "kind:Terraform::Resource" --count-by module
2 app/(root)
1 app/module.cdn
1 network/(root)
— 4 total · 3 groups by module
GroupingGroups by
--count-by kindthe node’s kind. A Terraform resource groups by its resource type (aws_instance) and a data source by data.<type> (data.aws_ami), since every Terraform resource is Terraform::Resource in chant’s graph
--count-by moduleon a Terraform root, the module call chain from the entity’s address (module.cdn, module.cdn.module.bucket), with a root-module block grouped as (root). When the project has more than one root, the root name leads (app/(root), network/module.vpc), because the same module name in two roots is two modules. Any other node groups by the composite that expanded it
--count-by attr:<name>the value of attribute <name>, matched case-insensitively as --show does

A node with no value for the grouped dimension is counted under (none), so the groups add up to the total.

Combined with a query, only the matches are counted. With no query at all, the whole graph is: chant search --count-by kind lists every kind the estate holds. The provenance line still follows, so a grouped live answer says what backed it.

With --json, the object carries the groups in the same order and the total in place of matches. Each group lists the ids of its matches in ids, in match order, so a reader can draw a group without working out which group a node is in (#4382). It also carries provenance, liveFailures and drift as the --json document has them (explain is not carried here):

{
"$schema": "https://intentius.io/chant/schemas/workspace/search/v1/search.schema.json",
"contract": 1,
"chant": "0.124.46",
"at": null,
"query": "kind:Terraform::Resource",
"source": { "kind": "declared" },
"countBy": "module",
"groups": [
{ "group": "app/(root)", "count": 2, "ids": ["app/aws_instance.web", "app/aws_s3_bucket.assets"] },
{ "group": "app/module.cdn", "count": 1, "ids": ["app/module.cdn/aws_cloudfront_distribution.edge"] },
{ "group": "network/(root)", "count": 1, "ids": ["network/aws_vpc.main"] }
],
"total": 4,
"provenance": "declared only · no observation · physical ids unavailable"
}

The footer gives a small model a reason to trust the result instead of re-deriving it with a lossy CLI sweep:

  • the universe count — 4 of 6 EC2::Instance matched, a denominator the typed graph knows and a live sweep does not
  • inclusion evidence for derived facts — e.g. ✓ web1 internet-facing via subnet route
  • for each match and each edge term, the path it was reached by, as a chain of id.attr steps read from the match outward (the shortest path when there are several)
  • up to 8 near-misses with the query term each one fails
— 2 of 6 EC2::Instance matched (query: kind:EC2::Instance attr:internetFacing=true)
✓ BastionUsEast1 internet-facing via default-vpc
· excluded AppUsWest2 — fails attr:internetFacing=true
— 1 of 2 aws_instance matched (query: kind:aws_instance ->*aws_vpc.main)
path aws_instance.web.subnet_id -> aws_subnet.a.id; aws_subnet.a.vpc_id -> aws_vpc.main.id
· excluded aws_instance.batch — fails →*"aws_vpc.main" (no such edge)

Walking ->, each hop reads consumer.viaAttr -> producer.toAttr. Walking <-, it reads producer.toAttr <- consumer.viaAttr. A side the lexicon did not record is printed as the bare id.

Everything in the footer is a property of the query over the graph, not of any expected answer.

--json prints one JSON object on stdout and nothing else there. Warnings and errors still go to stderr, and the exit code is the one the text output would give. Everything the rows and footers say in prose is a field here, from the full node id of every match to a live read that failed. behold reads this document to draw the answer to a query.

The object is the search document of the workspace read contract (#4374), described by search.schema.json in @intentius/chant (https://intentius.io/chant/schemas/workspace/search/v1/search.schema.json). It opens with $schema, contract and chant, then workspace ({ name, root }) when the project is a member of a declared workspace, and at, which is always null here because plain search reads the working tree. chant workspace search prints the same document for every member of a workspace at once. A chant from before the release after 0.124.45 prints the fields below without the head.

Each match carries, for every edge term in the query that is not negated, the path it was reached by as a list of hops in walk order from the match. A hop keeps the edge’s own direction (from holds the reference, to is what it names) whichever way the term walked, so a reader finds the edge in the graph by its endpoints. Optional fields are left out when unknown.

{
"$schema": "https://intentius.io/chant/schemas/workspace/search/v1/search.schema.json",
"contract": 1,
"chant": "0.124.46",
"at": null,
"query": "kind:aws_instance ->*aws_vpc.main",
"source": { "kind": "snapshot", "env": "prod", "snapshot": "latest", "commit": "a1b2c3d4e5f6", "timestamp": "2026-08-01T03:15:00.000Z" },
"matches": [
{
"id": "east::aws_instance.web",
"displayId": "aws_instance.web",
"kind": "Terraform::Resource",
"physicalId": "i-0abc",
"show": { "instance_type": "t3.micro" },
"paths": [
{
"term": "->*\"aws_vpc.main\"",
"dir": "out",
"hops": [
{ "from": "east::aws_instance.web", "fromAttr": "subnet_id", "to": "east::aws_subnet.a", "toAttr": "id" },
{ "from": "east::aws_subnet.a", "fromAttr": "vpc_id", "to": "east::aws_vpc.main", "toAttr": "id" }
]
}
]
}
],
"provenance": "observed from snapshot a1b2c3d taken 2026-08-01T03:15:00.000Z · bound 1/1",
"explain": {
"universe": { "matched": 1, "of": 2, "kinds": ["aws_instance"] },
"evidence": [],
"nearMisses": [ { "id": "east::aws_instance.batch", "fails": "->*\"aws_vpc.main\"" } ],
"moreExcluded": 0
}
}
FieldMeaning
$schema, contract, chantthe schema’s $id, the contract version (1) and the chant that wrote the document
workspace{ name, root } of the declared workspace the project belongs to; absent outside one
atthe git revision searched: always null for plain search, which reads the working tree
querythe query as given
source.kinddeclared, live or snapshot
source.envthe --env of a live or snapshot answer
source.snapshotthe snapshot ref asked for with --snapshot or --at, such as latest
source.commit, source.timestampa snapshot’s commit and when it was taken
source.deptha live answer only, when the Terraform plan read (CHANT_TERRAFORM_OBSERVE=plan) answered a bound match: plan, live+plan, or mixed when matches were read at different depths. Absent otherwise
source.readswith depth or planFellBackTo: the distinct reads that answered the bound matches (live, state, plan, live+plan)
source.planFellBackTothe plan read was asked for and failed: state if the state read answered, declared if nothing did
matches[].idthe graph’s full node id, stack-qualified (<stack>::<name>) when the project has side-by-side stacks and the answer is live or from a snapshot; two stacks’ resources of the same name have two ids
matches[].displayIdthe bare name the text row prints
matches[].kindthe node’s IR kind; a Terraform block’s is Terraform::Resource, and kind:aws_instance matches it by its resource type
matches[].physicalIdthe physical id, when known
matches[].unobservedwhy a live read could not cover this row (e.g. read-failed)
matches[].showwith --show: each requested attribute the row carries, keyed by the name the graph spells it with; a name no row carries is left out
matches[].paths[].termthe edge term, as the query would spell it
matches[].paths[].dirout for ->, in for <-
matches[].paths[].hops[]from, fromAttr (the edge’s viaAttr), to, toAttr; on a capability’s path also edge, the name the rules give it
matches[].paths[].because[]on a cap: term: { capability, id, hops } for each set the match relied on
capabilities[]when the query names a capability: { name, source, notEvaluated }, the observation its facts came from and what it leaves out
provenancethe provenance line the text prints, without its leading dash
liveFailurespresent when a live read failed, one entry per lexicon (<lexicon>: <cause>); the command exits 1 as it does without --json
explainwith --explain
explain.universematched of of nodes of the matched kinds (an empty kinds means every node, when nothing matched)
explain.evidence[]{ id, attr, via }: a derived fact a match qualified on, and what it was derived through
explain.nearMisses[]{ id, fails }: up to 8 excluded nodes of the universe, and the first query term each one fails, spelled as the query would spell it
explain.moreExcludedhow many more were excluded than nearMisses lists
driftwith --check-live or --check-snapshot; see below
error{ code, message, hint? }, see below

drift holds the query-scoped check. against is live or snapshot, checked is how many matched rows were checked, and count is how many drifted (the number the text summary prints). The lists are the lifecycle diff --live categories, scoped to the matched rows: missing, orphan and disappeared (ids), drifted ({ id, path, old, new }, one entry per changed attribute), newlyObserved (ids), runtimeChildren ({ id, type, owner }) and unobserved ({ id, type?, reason, detail? }). depth is deep when the snapshot was recorded deeper than the identity-level comparison made here. When --check-snapshot finds no recorded snapshot, drift is { "against": "snapshot", "unavailable": "<reason>" } and nothing else.

A query the grammar refuses, a missing or unknown --env, or any other refused invocation prints a document with the error instead of matches, and exits 1. The code is one of the read contract’s reason codes:

CodeWhen
query-invalidno query (without --count-by), a term the grammar refuses, or a --count-by that is not a grouping
usage-invalida flag without the one it needs (--check-live without --snapshot, --live or --snapshot without --env), or two that exclude each other
environment-unknown--env names an environment the config does not declare
snapshot-unavailableno snapshot is recorded for the environment, or the ref is not one replay takes
build-failedthe build of a --live read failed, or a build root failed to render
index-unavailable--index names no version the store holds under that scope key, or a version stamped for another scope or version
{
"$schema": "https://intentius.io/chant/schemas/workspace/search/v1/search.schema.json",
"contract": 1,
"chant": "0.124.46",
"query": "effectiveIngress:tcp:22",
"error": {
"code": "query-invalid",
"message": "\"effectiveIngress:tcp:22\" is not a term — there is no \"effectiveIngress:\" prefix",
"hint": "for an attribute, say attr:effectiveIngress=tcp:22; the prefixes are kind:, attr:, tag:, id:, and ->/<- for edges"
}
}

A query whose first term is an outgoing edge starts with -, and is read as the query: chant search "->kind:S3::Bucket". Any token after a bare -- is taken as written too, which is the spelling for a query that would otherwise look like a flag: chant search -- "-x". An unknown short flag such as -x is refused with Unknown flag, like an unknown --flag.

--live builds the project first (a failing build stops the search), observes owned resources in the environment, and overlays live identity onto the declared graph. The declared side carries the topology so ->/<- resolve, and the live side supplies physical ids and live-only attributes.

Multi-stack projects are observed per stack, and results display the bare logical id without the stack qualifier.

Every answer ends with a provenance line saying what backed it: declared only, observed live · bound N/M, or observed from snapshot. When the Terraform plan read answered, the line says so: observed at the plan depth (terraform plan -lock=false) · bound N/M, or observed live and at the plan depth (...) for a live root. Matches read at different depths name each. If the plan read fell back, the line ends the plan read fell back to state (or live-plan alone, for a live root whose plan read failed, or declared). A live read that fails (unreachable endpoint, denied credentials) is reported as a failure rather than an empty estate. Each row the read could not cover prints (unobserved: read-failed) where its physical id would go, stderr names the lexicon and the underlying error, the footer reads live read failed (<lexicon>) · N/M rows unobserved, and the command exits 1. Derived attributes that fold over live topology are not advertised in that case, since nothing was read to compute them. The rows printed are what the source declares, labelled as such; answer from a recorded snapshot with --at latest or drop --live for a declared-only query.

On a live or recorded answer (--live, --at), each node the read observed carries _ownership beside _status (#4534). It is chant’s own verdict from the resource’s marker, the tags or labels chant stamps (ownership): owned when the resource carries chant’s marker, foreign when the read found none. A node with no _ownership is unknown: the lexicon has no marker channel on that read, or nobody observed the node. It is an attribute like any other, so the usual terms read it:

Terminal window
# observed resources chant's marker does not claim
chant search --live --env prod "attr:_ownership=foreign"
# the verdict beside each row, and a count by it
chant search --live --env prod "kind:aws_s3_bucket" --show _ownership
chant search --live --env prod "" --count-by attr:_ownership

On a choudoufu live root the verdict comes from choudoufu’s marker sweep: a bound resource is owned and one another estate’s marker holds is foreign. The engine’s own verdict (bound, unowned, pending, foreign) is in the node’s engine facts and is never read by a search term. A pulumi member’s nodes have no _ownership, since chant reads plunge’s records and not its markers. A live read selects owned resources only, so on Kubernetes an object with no chant label does not reach the answer at all.

id:<cloud id> finds a resource from an id you hold, an ARN from an alert or an instance id from a log line (#4534). It reads the node’s observed physicalId, and the cloudIds of its engine facts (a choudoufu or plunge node, or an indexed one). The match is exact, case included, and the whole rest of the term is the id, so an ARN’s colons need no quoting. A bare word reads neither field. A declared-only search has no physical ids, so use it with --live, --at or an index.

Terminal window
chant search --live --env prod "id:i-0abc123"
chant search --at latest --env prod "id:arn:aws:s3:::my-bucket" --show _ownership

--index <ref> answers from an index version: a stamped, read-only graph of observed resources that behold index wrote to the project’s snapshot store. Nothing is read from the cloud.

The ref starts with the index’s stamp.scopeKey, for example cloud/aws/000000000000/0123456789ab. Add @latest (the default) or @<version> to pick a version. A version is a name the store lists, such as 20261026T090000Z-0123456789ab, and the .json suffix is optional. A full store path ending in /<version>.json works too.

With a project, the rows are the declared graph. This is the canvas --live uses, per stack when chant.config declares stacks. Each declared row is joined to the index through its lexicon’s index match. For aws the match is the CloudFormation primaryIdentifier the declaration spells. It is looked for in the region of the entity’s stack, and at global for the global types such as IAM roles. For k8s the match is the object’s kind with its namespace and name, as the declaration spells them. A namespaced object is looked for at its namespace as <namespace>/<name>, and a cluster-scoped one at cluster by name. The kind is matched at any version, so an index that listed apps/v1beta2 still binds a declared apps/v1 Deployment. The uid is assigned by the API server, so no declaration can match by it. A k8s declaration with no metadata.namespace reads no-binding. Every row gets one verdict, and a row that did not bind prints its verdict where the physical id would go:

VerdictPrintedMeaning
boundthe native idone indexed resource carries the identity; its observed attributes are on the row
absent(absent)the index listed that type and location and holds no such resource
not-observed(not observed: <reason>)the index did not look. filtered is a type or location outside its scope or tag filter, or an index of another substrate. not-reached means nothing covered it. unsupported-kind means the lexicon has no index match
no-binding(no-binding)the declaration spells no identity to match by (a server-assigned id such as a queue URL, or a Ref), or it names no region and nothing matched
ambiguous(ambiguous: N candidates)two or more indexed resources carry the identity, and none is bound

An indexed resource that no declared row binds is not a row, just as an undeclared resource is not a row of a --live answer. The aws and k8s lexicons have an index match today. Rows of the other lexicons read not observed: unsupported-kind.

With no project (no lexicon in a chant.config or in source), the index is the whole graph. Each indexed resource is a row with its path-style id and its native id. The query grammar applies as it does to a declared graph, edge terms walk the index’s derived edges, and tag: reads each resource’s tags.

The provenance line names the version and when it was read:

— observed from index cloud/aws/000000000000/0123456789ab@20261026T090000Z-0123456789ab taken 2026-10-26T09:00:00.000Z · bound 2/5 · absent 1 · no-binding 2
— index only, no project · cloud/aws/000000000000/0123456789ab@20261026T090000Z-0123456789ab taken 2026-10-26T09:00:00.000Z by behold-lister 0.4.0 · 12 rows

Under --json, source.kind is index, and source carries the ref, the scope key, the version, observedAt, the digest, by and project. Each declared match carries a join object with its verdict and detail. A bound row’s join adds node and nativeId. A row that is not observed adds reason, and an ambiguous one adds candidates.

--check-live and --check-snapshot compare only the matched rows against the observation the primary answer did not use. Where lifecycle diff --live asks “has anything changed anywhere”, this asks “is what I was just told still true”, about the resources in front of you, in the same command that produced them.

$ chant search "kind:EC2::Instance" --env prod --snapshot latest --check-live
webServer AWS::EC2::Instance i-e392157f576caf855
launchTemplateServer AWS::EC2::Instance i-ee2744528df10ea0d
⚠ webServer attributes.SubnetId: subnet-2faec80b → subnet-9f01ab3c — drifted
— checked against a live read · 1 of 2 matched drifted

The reverse framing answers live and checks the record for staleness:

Terminal window
chant search "kind:EC2::Instance" --env prod --live --check-snapshot

Both reuse diffLive scoped to the matched entity ids. That is the function lifecycle diff --live calls, so the categories and what counts as drift are identical: missing/orphan/disappeared/drifted are drift, newlyObserved and runtime children are reported but are not, and unobserved (a read that could not look) stays distinct from both. A resource the query scoped out is a fourth case. It is never reported as missing or unobserved, because it was never asked about.

--check-live requires --snapshot (or --at); --check-snapshot requires --live. Each checks the answer against the source it did not already use. --fail-on-drift exits non-zero when the check finds drift, so it is usable as a CI gate; a missing snapshot for --check-snapshot is only a note on stderr, since the live answer already stands. A snapshot recorded with chant lifecycle snapshot <env> --deep compares here at identity depth regardless, and the footer says so, rather than reporting every property the identity-depth side never recorded as drift.

Terminal window
# offline, over the declared graph
chant search "kind:Bucket"
chant search "tag:Environment=prod"
# live estate questions
chant search "kind:EC2::Instance attr:internetFacing=true" --live --env dev
chant search "kind:Instance ->attr:MapPublicIpOnLaunch=true" --live --env dev --explain
# from an index version, with or without a project
chant search "kind:S3::Bucket" --index cloud/aws/000000000000/0123456789ab
chant search "tag:team=data" --index cloud/aws/000000000000/0123456789ab@20261026T090000Z-0123456789ab --json
# how many of each
chant search --count-by kind
chant search "kind:Terraform::Resource" --count-by module
chant search "kind:EC2::Instance" --count-by attr:region --live --env dev
# query-scoped drift
chant search "kind:EC2::Instance" --env prod --snapshot latest --check-live
chant search "kind:EC2::Instance" --env prod --live --check-snapshot --fail-on-drift
CodeMeaning
0Query ran (including zero matches — prints (no matches)), including a scoped check that found drift without --fail-on-drift
1An --index ref that names no readable version, missing query (without --count-by), a query the grammar refuses, a --count-by value that is not a grouping, missing/unknown --env, build failure in --live mode, a build root (terraform.roots, k8s.kustomize.roots) that failed to render, a --live/--check-live read that failed for at least one lexicon (rows are printed, marked unobserved), --check-live/--check-snapshot used without its required flag, --fail-on-drift used without either, or --fail-on-drift with drift found

chant