π¦«ποΈ dispatch to foreman
π§ task enqueued
ββ priority = ?
ββ yieldage = ?
ββ leverage = ?
title
fix(ec2Instance): privateIp/publicIp permadrift β dot-path readonly keys are never omitted
description
.what
DeclaredAwsEc2Instance reads UPDATE on every plan, forever, on any instance that AWS
assigned an ip to. an apply lands, and the next plan reads the identical change again β a
permadrift, not ordinary drift.
the two fields at fault are already declared readonly. the declaration is correct; it is
simply never honored, because it uses a dot-path form that omitReadonly cannot see.
"interface": DeclaredAwsEc2InstanceNetworkInterface {
- "privateIp": "10.20.1.84",
- "publicIp": "54.208.209.118",
"publicIpEnabled": true,
"sourceDestChecked": false,
.why it never converges
the chain, traced end to end and read off the shipped source:
| step |
where |
what happens |
| 1 |
DeclaredAwsEc2Instance.readonly |
declares ['network.interface.privateIp', 'network.interface.publicIp'] β dot-paths, with a .note that says "nested via dot-path" |
| 2 |
getReadonlyKeys (domain-objects) |
returns those strings verbatim, with no dot-path expansion |
| 3 |
omitReadonly (domain-objects) |
hands them to type-fns's omit, which matches top-level keys only β so 'network.interface.privateIp' matches no key, and the omit is a silent no-op |
| 4 |
omitReadonly's recursion |
does descend into network, but getReadonlyKeys returns explicit readonly keys only for DomainEntity. DeclaredAwsEc2InstanceNetwork and ...NetworkInterface are DomainLiteral and declare no readonly of their own, so the recursion removes neither field |
| 5 |
computeChange (declastruct) |
both fields survive into serialize(), so checkAreResourcesEquivalent is false β UPDATE |
so the field is readonly in intent and settable in effect. no write can close the gap,
because AWS owns both values and the desired side can never carry them.
the root cause is upstream, in domain-objects β omitReadonly does not support the
dot-path readonly form that declastruct-aws already uses and documents. this issue is filed
here because this is where the symptom surfaces; see .the two fix sites for the routing.
.the blast radius
it is loud, not dangerous β but it is not cosmetic either:
- every plan on every ec2 instance with an assigned ip reads
UPDATE
- it trains the operator to skim the plan, which is the one habit a declarative tool exists to prevent
- worse on an immutable resource: an operator who sees a perpetual
UPDATE on an instance
cannot tell it apart from a real drift that needs a terminate-and-recreate
.verified benign at apply
worth stated plainly, so nobody treats this as urgent: the apply does converge, and it does
not attempt a recreate.
getEc2InstanceImmutableDrift compares exactly four things:
template
network.subnet
network.security.groups
network.interface.publicIpEnabled
privateIp and publicIp are absent from all four, so no immutable drift is raised and
setEc2Instance does not dead-end. confirmed live on 2026-08-14 against a camp NAT: the plan
read UPDATE, the apply converged, and the next plan read UPDATE again.
.the two fix sites
(a) cause-level, in domain-objects β recommended
teach omitReadonly to honor a dot-path readonly key: split on ., walk to the parent, and omit
the leaf. getReadonlyKeys already returns the paths, so the change is bounded to the omit step.
this is the honest fix, per rule.require.solve-at-cause:
- it repairs a contract
declastruct-aws already relies on and documents
- it fixes every other resource that declares a nested readonly, present and future
- without it, the dot-path form stays a silent trap: it looks declared, it reads as honored, and
it omits neither field
it also wants a clamp that bites β a test where a dot-path readonly field differs between two
otherwise-identical objects, asserted to compare equal.
(b) local, in declastruct-aws β the workaround
move the readonly declaration down onto the nested object, or strip the two fields from the
remote cast before the compare.
this quiets the plan with no domain-objects release. but it leaves the dot-path form broken
for every other caller, and it papers over the contract rather than repairs it β so it is worth
taken only as a stopgap, and worth a note that points at (a).
.repro
any ec2 instance with an AWS-assigned ip. observed on a NAT instance in a private-subnet vpc
(publicIpEnabled: true, sourceDestChecked: false).
- declare a
DeclaredAwsEc2Instance, apply it
- plan again β
UPDATE, with privateIp / publicIp as the only difference
- apply again β converges
- plan again β the identical
UPDATE
.the test that settles it
apply, then re-plan.
- the change is gone β ordinary drift
- the change is identical β a permadrift, and the compare is the defect, never the resource
β οΈ that test yields a false positive on an immutable resource, where an apply throws rather
than converges. an ec2 instance is immutable to a template/subnet/security-group change, so on
this resource the sharper test is: does the apply throw an immutability error (ordinary drift,
blocked by shape) or succeed and re-drift (a true permadrift)? this one succeeds and
re-drifts.
.kin
three permadrifts of three distinct causes suggests the compare seam is worth a pass of its own,
rather than three point fixes.
π¦«ποΈ dispatch to foreman
title
fix(ec2Instance): privateIp/publicIp permadrift β dot-path readonly keys are never omitted
description
.what
DeclaredAwsEc2InstancereadsUPDATEon every plan, forever, on any instance that AWSassigned an ip to. an apply lands, and the next plan reads the identical change again β a
permadrift, not ordinary drift.
the two fields at fault are already declared
readonly. the declaration is correct; it issimply never honored, because it uses a dot-path form that
omitReadonlycannot see..why it never converges
the chain, traced end to end and read off the shipped source:
DeclaredAwsEc2Instance.readonly['network.interface.privateIp', 'network.interface.publicIp']β dot-paths, with a.notethat says "nested via dot-path"getReadonlyKeys(domain-objects)omitReadonly(domain-objects)type-fns'somit, which matches top-level keys only β so'network.interface.privateIp'matches no key, and the omit is a silent no-opomitReadonly's recursionnetwork, butgetReadonlyKeysreturns explicit readonly keys only forDomainEntity.DeclaredAwsEc2InstanceNetworkand...NetworkInterfaceareDomainLiteraland declare no readonly of their own, so the recursion removes neither fieldcomputeChange(declastruct)serialize(), socheckAreResourcesEquivalentis false βUPDATEso the field is readonly in intent and settable in effect. no write can close the gap,
because AWS owns both values and the desired side can never carry them.
.the blast radius
it is loud, not dangerous β but it is not cosmetic either:
UPDATEUPDATEon an instancecannot tell it apart from a real drift that needs a terminate-and-recreate
.verified benign at apply
worth stated plainly, so nobody treats this as urgent: the apply does converge, and it does
not attempt a recreate.
getEc2InstanceImmutableDriftcompares exactly four things:templatenetwork.subnetnetwork.security.groupsnetwork.interface.publicIpEnabledprivateIpandpublicIpare absent from all four, so no immutable drift is raised andsetEc2Instancedoes not dead-end. confirmed live on 2026-08-14 against a camp NAT: the planread
UPDATE, the apply converged, and the next plan readUPDATEagain..the two fix sites
(a) cause-level, in
domain-objectsβ recommendedteach
omitReadonlyto honor a dot-path readonly key: split on., walk to the parent, and omitthe leaf.
getReadonlyKeysalready returns the paths, so the change is bounded to the omit step.this is the honest fix, per
rule.require.solve-at-cause:declastruct-awsalready relies on and documentsit omits neither field
it also wants a clamp that bites β a test where a dot-path readonly field differs between two
otherwise-identical objects, asserted to compare equal.
(b) local, in
declastruct-awsβ the workaroundmove the readonly declaration down onto the nested object, or strip the two fields from the
remote cast before the compare.
this quiets the plan with no
domain-objectsrelease. but it leaves the dot-path form brokenfor every other caller, and it papers over the contract rather than repairs it β so it is worth
taken only as a stopgap, and worth a note that points at (a).
.repro
any ec2 instance with an AWS-assigned ip. observed on a NAT instance in a private-subnet vpc
(
publicIpEnabled: true,sourceDestChecked: false).DeclaredAwsEc2Instance, apply itUPDATE, withprivateIp/publicIpas the only differenceUPDATE.the test that settles it
apply, then re-plan.
than converges. an ec2 instance is immutable to a template/subnet/security-group change, so on
this resource the sharper test is: does the apply throw an immutability error (ordinary drift,
blocked by shape) or succeed and re-drift (a true permadrift)? this one succeeds and
re-drifts.
.kin
fix(iam): single-element condition array permadrifts. same class, different cause:there AWS canonicalizes a one-element array into a scalar; here a readonly field is never
omitted. both are lossy round-trips through a store, and both need the fix at the seam both
sides pass through, never at the read alone
fix(budget): compare notification subscribers as a set. same class again (anorder-sensitive compare over an order-insensitive value)
three permadrifts of three distinct causes suggests the compare seam is worth a pass of its own,
rather than three point fixes.