Resource identity
A resource’s cloud identity answers one question: what real-world object does this state row describe, in a form globally unique across accounts, regions, clusters — and gantrycd installations. Two rows with the same identity and the same resource type are the same object. It feeds cross-stack duplicate detection and dependency inference — a data source in one stack reading an object another stack manages (see What inference does with it).
Implementation: pkg/domain/resource_identity.go — the contract, the two
gantrycd: tag keys, the IdentityProvider interface and the IdentityRegistry that
resolves a provider source to the implementation covering it — plus one
resource_identity_<provider>.go per supported provider. Which providers a
build mounts is a wiring decision, not a domain one: the backend builds the
registry in cmd/backend/main.go, in the same block as the SCM, notification
and SSO registries, and passes it down — domain.NewIdentityRegistry(domain.AWSIdentity{}).
The contract: supported or not
Identity is asserted only by an explicit, audited per-provider rule, and a rule accepts a value only when it parses as that provider’s globally-anchored grammar. Everything else yields nothing:
- an unsupported provider — including a private-registry mirror of a supported one, because an unlisted source string is an unaudited rule;
- a data source (see below);
- any value that is unique only within some endpoint the state does not
record. A Kubernetes namespace called
platformexists in every cluster; nothing in the value says which one. A subnet id likesubnet-0abcis unique within one account and region; the value records neither.
The asymmetry is deliberate: a missing identity costs at most an undetected
finding, while a guessed one merges two real objects — a false duplicate today,
a false dependency edge between unrelated stacks. There is no heuristic fallback
and no confidence tier. This replaced a generic waterfall (first non-sensitive
string among arn, id, self_link) that accepted random_pet:fluffy as a
cloud identity. The decision was measured across 14 providers and ~5,400
resource types.
Sensitive values need no special case on the discovery path: the state
inventory replaces sensitive leaves with the literal (sensitive value), which
no identity grammar accepts. A declared id is taken verbatim and gets no grammar
check at all, so a tag sourced from a sensitive variable stores that placeholder
— see Declaring identity yourself.
Supported providers
| Provider | Rule | Refuses |
|---|---|---|
hashicorp/aws (both registry FQNs) | the resource’s own arn attribute, when structurally a valid ARN (arn:partition:service:region:account:resource; region/account may be empty — S3, IAM) | bare ids; foreign-ARN types whose own key admits several rows carrying that ARN: aws_cloudwatch_event_target (the invoked Lambda), aws_secretsmanager_secret_version (the secret), aws_notifications_channel_association (the channel), aws_organizations_delegated_administrator (the account, once per service) and aws_quicksight_group_membership (the member, once per group) |
Planned next: google (self_link / projects/… paths). Deliberately
unsupported for now: kubernetes — metadata.uid is unique per cluster
lifetime, is stripped by kubernetes_manifest, and survives cluster
clones/restores, so it cannot assert a duplicate on its own; it waits for the
provider-endpoint fingerprint work.
Coverage bounds (aws, measured)
Measured against hashicorp/aws v6.61.0: 898 of 1,709 types expose a
top-level arn and can get an identity. The 633 id-only types and the 178
carrying neither arn nor id yield nothing, deliberately. An absent identity
means “not asserted”, never “no duplicate exists” — and these numbers move with
every provider release.
The foreign-arn exception list is an open audit, not a closed set. “arn
is a required input” finds most such types mechanically, but not all
(aws_secretsmanager_secret_version’s is computed and deprecated), so a missed
one is a false duplicate until it is added.
A foreign ARN is not on its own enough to exclude a type — the type’s own key
must also admit several rows carrying it, and that is readable from the provider
schema’s required attributes. aws_organizations_delegated_administrator is
keyed (account_id, service_principal), so one account legitimately appears once
per service and is excluded. aws_cloudtrail_organization_delegated_admin_account
looks almost identical but is keyed on account_id alone, so at most one exists
per organization — two of them really are two stacks registering the same
delegation, where destroying either deregisters the other, and it is not
excluded. Shape-matching without checking the key suppresses real conflicts.
Adding a provider
- A type implementing
IdentityProviderinpkg/domain/resource_identity_<name>.go—Sources()andIdentity().Identityreturns the id and the realm, because where both live is provider-specific: AWS reads them fromtagsandtags_all, google would read labels, kubernetes annotations. Plain Go inside: per-type exceptions areswitchcases, version gates areifs. Sources()lists every FQN the provider ships under (the OpenTofu and Terraform registries are distinct strings). Keeping them beside the rule that reads them is what stops a half-registration; the registry panics at startup if two providers claim one source.- One argument in the
NewIdentityRegistrycall incmd/backend/main.go.sampleexploremounts only the providers its fixtures generate data for — today, aws — so leave it alone until you add fixtures for yours. - A table test per accepted and refused shape, including the
(sensitive value)placeholder and at least one structurally-valid value that must still be refused (a foreign-object exception, if the provider has one). - A row in the table above.
The bar for a rule: the accepted value’s grammar must itself anchor the object globally (partition, subscription, project — something baked into the value). If identifying the object needs anything from outside the value — provider config, an endpoint, a cluster — the provider is not supportable yet.
Declaring identity yourself
Discovery is deliberately conservative, so operators get an explicit override.
Two tags, read from whichever metadata field the provider records in state. For
AWS that is both tags (what the resource itself declares) and tags_all
(the provider’s default_tags overlaid with them) — and which one a value
arrives in changes what it means, as the table shows.
| Tag | Effect |
|---|---|
gantrycd:resource-identity-id | Declares the identity, on the resource’s own tags. Beats discovery. Like a discovered one it matches only within a resource type, so it cannot link an alias pair — see the known miss below. Inherited from default_tags it suppresses instead of declaring, exactly like the opt-out: one id shared by every resource a stack manages would make them all one object, so gantrycd refuses rather than guessing which it meant. |
gantrycd:resource-identity-id = "null" | Opts out. No identity: excluded from duplicate detection and inference. In default_tags it opts out every taggable resource in the stack — see the bound below. |
gantrycd:resource-identity-realm | Scopes the identity to a named world, so values unique only within one endpoint stop colliding across endpoints. |
// gantrycd:resource-identity: ignore=true (a code comment, not a tag) | Opts out by address, for what a tag cannot reach: untaggable types and data sources. Also the way to stop a false inferred dependency — a declaration only overrides its trigger policy. Grammar in dependency_graph.md. |
provider "aws" {
endpoints { s3 = "http://localhost:4566" }
default_tags {
tags = { "gantrycd:resource-identity-realm" = "localstack-alice" }
}
}
The declarations only reach taggable types
Both tags are read from tags / tags_all, so they reach only resources
whose schema has them. Measured against aws v6.61.0: 157 of the 898 types that
expose a top-level arn are untaggable — they take no tags at all, or use a
tag block that never lands in either map (aws_autoscaling_group,
aws_cloudwatch_log_stream, the aws_cloudfront_*_policy family, …).
That figure comes from reading the hashicorp/aws v6.61.0 provider schema
by hand; nothing in this repository derives or checks it, and no test will
fail when a provider release moves it. Treat it as a dated observation about
the shape of the problem, not a constant the code depends on — the code never
consults a type list, only whether the row it has carries the tag.
On those types the tag declaration, the realm and the tag opt-out all do
nothing, and gantrycd falls back to the discovered ARN. The
gantrycd:resource-identity: ignore=true comment is the per-address escape
hatch that does reach them. Three consequences worth stating plainly:
- A stack-wide opt-out in
default_tagsleaves them asserting identities. - Two emulator endpoints scoped with
gantrycd:resource-identity-realmstill collide on their untaggable resources, because those rows carry no realm. - Every one of the five foreign-
arnexception types is untaggable, so a missed sixth cannot be corrected by a tag — the operator disclaims those addresses with the comment until the rule is added.
Closing this without operator action needs the provider-config fingerprint below, not another tag.
A realm is a short token ([A-Za-z0-9._-], up to 64) — a label, not a URL. An
unparseable one refuses the identity rather than ignoring the realm, since
ignoring it would silently widen the claim. Realms only match realms: a
resource declaring one never matches a resource that declared none, because
nothing proves they are the same world.
null is the spelling that survives, not a bare null: HCL drops a null value
from a tag map before it ever reaches state. An empty value means the same.
What this solves, and what remains a bound
An ARN anchors to a partition, account and region — not to a verified
endpoint. An AWS-compatible API (LocalStack, MinIO, another cloud’s S3) emits
well-formed ARNs, and the endpoint appears nowhere in state: the provider’s
endpoints block is provider configuration, which OpenTofu never writes there.
Two stacks pointed at different such endpoints would otherwise carry identical
ARNs for unrelated objects.
gantrycd:resource-identity-realm is the answer, and it is explicit rather than
guessed — gantrycd does not try to detect emulators. The bound that remains is
that it must be declared: an operator who runs two endpoints and sets no
realm still gets the collision. Closing that without operator action needs a
runner-side provider-config fingerprint, which is also what would make
endpoint-scoped providers like kubernetes supportable at all.
Two things do not help, checked against the provider schema (aws v6.61.0):
default_tags is itself provider configuration — its effect reaches state as
tags_all, which is exactly why the tags above work, but tags alone cannot
serve as an automatic scope (two stacks managing the same object routinely
record different tags, which would split real duplicates). And region, on
1,522 of 1,700 types since provider v6, is already inside the ARN.
How it is stored and compared
An identity is id plus, when an operator declared one, realm — two columns
(resource_index.cloud_identity_id, cloud_identity_realm) and two wire fields.
Two resources are the same object when their id, realm and resource type
all match. The resource type comes from the row itself, and it matters: several
types legitimately carry another object’s ARN — aws_sns_topic_policy.arn is
the topic’s own, identical to aws_sns_topic.arn — so without it a stack
managing a topic and a stack managing that topic’s policy would read as
duplicate-managing one object.
The cost is a known miss: provider aliases for one object (aws_lb/aws_alb,
aws_vpc/aws_default_vpc) are different types, so a genuine duplicate across
an alias pair goes unreported. A miss is always the acceptable side.
Why data sources never store an identity
A data source reads an object; a managed resource owns it. If a data row stored the object’s identity, duplicate detection would group it with the very resource it reads — every cross-stack read would be reported as a duplicate. So data rows always store NULL. Dependency inference — which wants exactly that match — computes the data-side identity transiently at reindex time and looks up managed rows by it, without ever persisting it.
What inference does with it
A data row and a managed row in a different stack that share an id, a
realm and a resource type describe one object: one stack owns it, the other reads
it, so the reader depends on the owner. gantrycd records that as a dependency
edge, resource-scoped on both ends and marked discovered.
# stack "account" # stack "taloscluster"
resource "aws_acm_certificate" "wildcard" { ... } data "aws_acm_certificate" "wildcard" { ... }
# arn = arn:aws:acm:…:certificate/4948e3d3 attribute_values.arn = the same ARN
Everything on this page is therefore load-bearing twice over. The type check that
keeps aws_sns_topic_policy from reading as a duplicate of its topic also keeps
a data source from being pointed at the wrong owner; the realm that separates two
LocalStack endpoints also separates their dependency graphs; and the opt-out
excludes a resource from both. Which is why “missing beats wrong” is the rule:
a guessed identity draws an edge between stacks that have nothing to do with each
other.
What inference is allowed to say with it is bounded separately — it never asks
for automation (every inferred edge is manual, which does still combine with a
declared always and can hold it down), and a declaration wins over its trigger
policy by address coverage: the same address, an ancestor of it, or the
dependency stack as a whole. The inferred dependency itself remains graph and
diagnostic evidence. Those rules, the write path, and how the edges heal live in
Cross-Stack Dependency Graph → Inferred dependencies.
Deferred by design
- Version-aware rules: no version signal exists in the pipeline today (the
state’s per-resource
schema_versionis not captured). Capturing it is a small additive runner change that lands with the first rule that reads it; until then, grammar validation is what makes field changes safe — a renamed field degrades to no-identity, never to a wrong one. - Realm scope: a verified backend/provider realm would let endpoint-scoped
providers (kubernetes and friends) be supported at all. Nothing is reserved
for it in the format yet — that shape gets decided when the realm exists,
alongside
gantrycd_state_outputsdiscovery re-enablement.