chant search
Synopsis
Section titled “Synopsis”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]Description
Section titled “Description”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.
Query grammar
Section titled “Query grammar”Space-separated terms. All terms must match (AND). Quote phrases to keep them together.
| Term | Matches |
|---|---|
word | case-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, <-*X | the far end matching X is any number of hops away, one or more |
->{n}X, <-{n}X | exactly n hops away |
->{m,n}X, <-{m,n}X | between m and n hops away, inclusive ({m,} is m or more) |
->[via=<attr>]X, <-[via=<attr>]X | only edges whose consumer attribute (viaAttr) is <attr> |
->[to=<attr>]X, <-[to=<attr>]X | only 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>]X | walk 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.
# instances that reference a public subnetchant 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.
# everything that reads aws_vpc.main, directly or through other resourceschant search "<-*aws_vpc.main" --explain
# resources within two hops of an IAM rolechant search "->{1,2}kind:aws_iam_role"
# VPCs some resource reads through vpc_idchant search "kind:aws_vpc <-[via=vpc_id]"kind: on a Terraform resource
Section titled “kind: on a Terraform resource”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 _:
| Term | Matches |
|---|---|
kind:aws_instance | that type exactly |
kind:aws_iam_role | the 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:aws | every aws_ type, since a bare provider name is the same prefix |
kind:Terraform::Resource, kind:Resource | every resource, as before |
kind:data.aws_ami | the 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.
chant search "kind:aws_iam_role"chant search "kind:aws_iam_ !kind:aws_iam_role" # IAM types other than rolesDerived attributes
Section titled “Derived attributes”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
chant search "kind:EC2::Instance attr:internetFacing=true" --live --env devchant search "kind:EC2::Instance attr:internetFacing=true attr:effectiveIngress=tcp:22:0.0.0.0/0" --live --env dev --explain --show InstanceIdThe same two facts are folded onto a Terraform root’s aws_instance blocks,
read from terraform.roots in chant.config.ts:
effectiveIngressfollowsvpc_security_group_ids,security_groupsandlaunch_template(to the template’svpc_security_group_ids), and reads inlineingressblocks,aws_security_group_ruleresources withtype = "ingress"andaws_vpc_security_group_ingress_ruleresources.internetFacingfollowssubnet_idandaws_route_table_associationto a route table whose default route goes to anaws_internet_gateway, written either as an inlinerouteblock or as anaws_routeresource. 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" --explainnet/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=trueCapabilities
Section titled “Capabilities”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.
# what can be reached on SSH from the internetchant search "cap:internet-reachable(22)" --explain
# if this role is compromised, what data can it reachchant search "cap:stores-data <-[cap=reaches]aws_iam_role.app_data"
# internet-facing hosts that can read restricted datachant search "cap:exfiltrates" --explainOne 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 declaredWith --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; ...| Flag | Type | Default | Description |
|---|---|---|---|
--live | boolean | false | Query live state overlaid on the declared graph (requires --env) |
--snapshot | string | — | Answer from a recorded snapshot instead of reading the estate now (latest; requires --env; mutually exclusive with --live) |
--at | string | — | The same as --snapshot, kept for compatibility. Elsewhere in the read contract --at is a git revision, so chant workspace search takes only --snapshot |
--index | string | — | 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 |
--env | string | — | Environment to observe (must be in chant.config environments when declared) |
--src | string | config sourceDir or . | Source directory to discover/build |
--show | string | — | Comma-separated attribute names to append to each result row |
--count-by | string | — | Print one count per group instead of one row per match: kind, module or attr:<name> (see Grouped counts) |
--explain | boolean | false | Print the match-universe footer, each match’s path for edge terms, and near-miss exclusions |
--capabilities | boolean | false | Print the capability catalog instead of answering a query; with --json, the capabilities document. Takes no query and no other search flag |
--json | boolean | false | Print the whole answer (rows, paths, provenance, --explain, drift, failures) as the read contract’s search document instead of rows and footers |
--ambient | boolean | false | Also report resources of a managed kind that exist without being declared or referenced |
--check-live | boolean | false | With --snapshot (or --at): additionally read the estate live and diff the matched rows against the snapshot the answer came from |
--check-snapshot | boolean | false | With --live: additionally diff the matched rows against the most recently recorded snapshot |
--fail-on-drift | boolean | false | With --check-live/--check-snapshot: exit non-zero when the scoped check finds drift |
Grouped counts
Section titled “Grouped counts”--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 module2 app/(root)1 app/module.cdn1 network/(root)— 4 total · 3 groups by module| Grouping | Groups by |
|---|---|
--count-by kind | the 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 module | on 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"}--explain
Section titled “--explain”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.attrsteps 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
Section titled “--json”--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 }}| Field | Meaning |
|---|---|
$schema, contract, chant | the 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 |
at | the git revision searched: always null for plain search, which reads the working tree |
query | the query as given |
source.kind | declared, live or snapshot |
source.env | the --env of a live or snapshot answer |
source.snapshot | the snapshot ref asked for with --snapshot or --at, such as latest |
source.commit, source.timestamp | a snapshot’s commit and when it was taken |
source.depth | a 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.reads | with depth or planFellBackTo: the distinct reads that answered the bound matches (live, state, plan, live+plan) |
source.planFellBackTo | the plan read was asked for and failed: state if the state read answered, declared if nothing did |
matches[].id | the 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[].displayId | the bare name the text row prints |
matches[].kind | the node’s IR kind; a Terraform block’s is Terraform::Resource, and kind:aws_instance matches it by its resource type |
matches[].physicalId | the physical id, when known |
matches[].unobserved | why a live read could not cover this row (e.g. read-failed) |
matches[].show | with --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[].term | the edge term, as the query would spell it |
matches[].paths[].dir | out 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 |
provenance | the provenance line the text prints, without its leading dash |
liveFailures | present when a live read failed, one entry per lexicon (<lexicon>: <cause>); the command exits 1 as it does without --json |
explain | with --explain |
explain.universe | matched 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.moreExcluded | how many more were excluded than nearMisses lists |
drift | with --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:
| Code | When |
|---|---|
query-invalid | no query (without --count-by), a term the grammar refuses, or a --count-by that is not a grouping |
usage-invalid | a 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-unavailable | no snapshot is recorded for the environment, or the ref is not one replay takes |
build-failed | the 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" }}Queries that start with -
Section titled “Queries that start with -”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 mode
Section titled “Live mode”--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.
Ownership
Section titled “Ownership”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:
# observed resources chant's marker does not claimchant search --live --env prod "attr:_ownership=foreign"
# the verdict beside each row, and a count by itchant search --live --env prod "kind:aws_s3_bucket" --show _ownershipchant search --live --env prod "" --count-by attr:_ownershipOn 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.
Cloud ids
Section titled “Cloud ids”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.
chant search --live --env prod "id:i-0abc123"chant search --at latest --env prod "id:arn:aws:s3:::my-bucket" --show _ownershipIndex mode
Section titled “Index mode”--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:
| Verdict | Printed | Meaning |
|---|---|---|
bound | the native id | one 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 rowsUnder --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.
Query-scoped drift
Section titled “Query-scoped drift”--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-livewebServer AWS::EC2::Instance i-e392157f576caf855launchTemplateServer AWS::EC2::Instance i-ee2744528df10ea0d⚠ webServer attributes.SubnetId: subnet-2faec80b → subnet-9f01ab3c — drifted— checked against a live read · 1 of 2 matched driftedThe reverse framing answers live and checks the record for staleness:
chant search "kind:EC2::Instance" --env prod --live --check-snapshotBoth 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.
# offline, over the declared graphchant search "kind:Bucket"chant search "tag:Environment=prod"
# live estate questionschant search "kind:EC2::Instance attr:internetFacing=true" --live --env devchant search "kind:Instance ->attr:MapPublicIpOnLaunch=true" --live --env dev --explain
# from an index version, with or without a projectchant search "kind:S3::Bucket" --index cloud/aws/000000000000/0123456789abchant search "tag:team=data" --index cloud/aws/000000000000/0123456789ab@20261026T090000Z-0123456789ab --json
# how many of eachchant search --count-by kindchant search "kind:Terraform::Resource" --count-by modulechant search "kind:EC2::Instance" --count-by attr:region --live --env dev
# query-scoped driftchant search "kind:EC2::Instance" --env prod --snapshot latest --check-livechant search "kind:EC2::Instance" --env prod --live --check-snapshot --fail-on-driftExit Codes
Section titled “Exit Codes”| Code | Meaning |
|---|---|
| 0 | Query ran (including zero matches — prints (no matches)), including a scoped check that found drift without --fail-on-drift |
| 1 | An --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 |