Problem
Onboarding an account is one-way. There is no path by which spore.host ever concludes "this account is gone" — so the state it leaves behind is permanent, and one piece of it is a genuine security hazard.
Three places encode this, all by construction rather than oversight:
- The account registry is append-only.
spore-host/lambda/portal-phone-home/registry.go exposes PutAccount and GetAccount and nothing else — no delete, no status, no last-seen. A registration cannot be retired.
- The reaper's account list is static config.
lambda/ttl-reaper/main.go:985 derives each sweep subdomain from r.accounts, populated purely from REAPER_ROLE_ARNS. An account is swept because a human typed it into a deploy, and stops being swept because a human edited it out.
dns-updater persists nothing. It writes Route53 and forgets. Route53 is therefore the only durable record that an account ever existed.
Two distinct failure modes
A. Forgotten-but-still-listed. The account goes away; its ARN stays in REAPER_ROLE_ARNS. The reaper burns a failing assume-role every 10 minutes forever, and each failure increments sum.Errors (main.go:416,439) — the field that is supposed to mean investigate this. Permanent expected failures train the operator to ignore the error count.
B. Forgotten-and-never-listed — silent, and live today. Records exist under {base36}.spore.host for an account absent from REAPER_ROLE_ARNS. Nothing sweeps it, nothing errors, nothing reports it.
Concrete instance: win11-test.4zlw3a1t.spore.host → 13.223.189.34. Subdomain 4zlw3a1t decodes to account 390967728545, which is not in REAPER_ROLE_ARNS, has no cross-account role, and appears nowhere in any spore-host repo. The IP is back in the general us-east-1 EC2 pool and no longer responds — so this name will eventually resolve to an unrelated instance once AWS reassigns the address. Stale DNS pointing at a stranger, not merely a dead name. (Found while verifying #438; deliberately left in place.)
What can and cannot be expired
The thing you would most want a TTL on is not ours to expire. deployment/cloudformation/ttl-reaper-cross-account-role.yaml creates spawn-ttl-reaper-ec2 in the customer's account; spore.host holds only the trust relationship. We cannot delete it — only they can. An IAM role with no caller is also inert and free, so no cost pressure forces the issue.
What is ours, ranked by hazard:
| Artifact |
Owner |
Hazard |
Wants |
Route53 records under {base36}.{domain} |
spore.host |
Real — IP returns to the EC2 pool; name resolves to a stranger |
Shortest grace period, not the longest |
Registry row (spore-portal-accounts) |
spore.host |
None (costs nothing; it is the audit trail) |
status + lastSeenAt, not deletion |
REAPER_ROLE_ARNS entry |
spore.host (config) |
Noise — poisons Errors |
Auto-quiesce after sustained failure |
spawn-ttl-reaper-ec2 role |
customer |
None |
Nothing — out of our control |
Note the DNS row inverts the usual instinct: the security consequence argues for expiring records sooner, not keeping them around just in case.
The liveness signal already exists, for free
The reaper assumes every role every 10 minutes. That is already a paid-for liveness probe, and it distinguishes exactly the states that matter:
- assume succeeds, zero managed instances for N days → dormant but reachable
- assume fails, role not found → the customer uninstalled
Role deletion is the natural uninstall gesture. No new API and no customer action beyond what they would already do.
Two traps that make the naive version dangerous
1. Correlated failure is indistinguishable from mass uninstall. The reaper's role ARN is …spawn-ttl-reaper-production-TTLReaperFunctionRole-ZJ84YZ2dCPei — a CloudFormation-generated physical ID. Recreating that stack changes the suffix and breaks every customer's trust policy simultaneously. A TTL that deprovisions on assume-role failure would then forget the entire customer base because we redeployed. Never act on a single failure; explicitly refuse to act when all accounts fail at once. Same instinct as the sweep's existing "never delete against a partial live set" guard (main.go:987-992).
2. We lose the ability to verify at the exact moment we would act. "Only the role is left, no other resources" requires a working assume-role to confirm — DescribeInstances is how we know the account is empty. Once the role is gone that check is impossible, so we would be deciding blind. win11-test is precisely this case: records present, emptiness unverifiable except by the weak inference that the IP stopped responding.
Proposed policy
Grace, then narrow, then never silently delete what cannot be verified.
- Unreachable — assume-role fails for K consecutive runs while other accounts succeed: mark the registry row
unreachable, stop counting it into Errors. No deletion.
- Dormant — assume-role works and zero managed instances for N days: safe to expire that account's DNS records, because emptiness is provable.
- Unmanaged subdomain — records under a
{base36} we hold no credentials for: report, never delete. We cannot distinguish "abandoned" from "someone forgot to add this account to REAPER_ROLE_ARNS," and deleting in the second case breaks a working spore.
- Explicit offboard — an operator/customer-initiated deprovision that may delete records, because intent is stated rather than inferred.
K and N are the open decisions. Suggest K=6 (one hour) and N=30 days, both configurable.
Suggested split
Two separable pieces; the first is worth doing regardless of how the policy lands.
Caveat on liveness tests
Checking AWS Organizations membership is not a general staleness test. BYOA accounts belong to customers and are legitimately outside our org. AccountNotFoundException only proved staleness for 4zlw3a1t because that happened to be one of our own test accounts.
Filed from the #438 verification work; see #438 for the sweep's current safety contract.
Problem
Onboarding an account is one-way. There is no path by which spore.host ever concludes "this account is gone" — so the state it leaves behind is permanent, and one piece of it is a genuine security hazard.
Three places encode this, all by construction rather than oversight:
spore-host/lambda/portal-phone-home/registry.goexposesPutAccountandGetAccountand nothing else — no delete, no status, no last-seen. A registration cannot be retired.lambda/ttl-reaper/main.go:985derives each sweep subdomain fromr.accounts, populated purely fromREAPER_ROLE_ARNS. An account is swept because a human typed it into a deploy, and stops being swept because a human edited it out.dns-updaterpersists nothing. It writes Route53 and forgets. Route53 is therefore the only durable record that an account ever existed.Two distinct failure modes
A. Forgotten-but-still-listed. The account goes away; its ARN stays in
REAPER_ROLE_ARNS. The reaper burns a failing assume-role every 10 minutes forever, and each failure incrementssum.Errors(main.go:416,439) — the field that is supposed to mean investigate this. Permanent expected failures train the operator to ignore the error count.B. Forgotten-and-never-listed — silent, and live today. Records exist under
{base36}.spore.hostfor an account absent fromREAPER_ROLE_ARNS. Nothing sweeps it, nothing errors, nothing reports it.Concrete instance:
win11-test.4zlw3a1t.spore.host→13.223.189.34. Subdomain4zlw3a1tdecodes to account 390967728545, which is not inREAPER_ROLE_ARNS, has no cross-account role, and appears nowhere in any spore-host repo. The IP is back in the general us-east-1 EC2 pool and no longer responds — so this name will eventually resolve to an unrelated instance once AWS reassigns the address. Stale DNS pointing at a stranger, not merely a dead name. (Found while verifying #438; deliberately left in place.)What can and cannot be expired
The thing you would most want a TTL on is not ours to expire.
deployment/cloudformation/ttl-reaper-cross-account-role.yamlcreatesspawn-ttl-reaper-ec2in the customer's account; spore.host holds only the trust relationship. We cannot delete it — only they can. An IAM role with no caller is also inert and free, so no cost pressure forces the issue.What is ours, ranked by hazard:
{base36}.{domain}spore-portal-accounts)status+lastSeenAt, not deletionREAPER_ROLE_ARNSentryErrorsspawn-ttl-reaper-ec2roleNote the DNS row inverts the usual instinct: the security consequence argues for expiring records sooner, not keeping them around just in case.
The liveness signal already exists, for free
The reaper assumes every role every 10 minutes. That is already a paid-for liveness probe, and it distinguishes exactly the states that matter:
Role deletion is the natural uninstall gesture. No new API and no customer action beyond what they would already do.
Two traps that make the naive version dangerous
1. Correlated failure is indistinguishable from mass uninstall. The reaper's role ARN is
…spawn-ttl-reaper-production-TTLReaperFunctionRole-ZJ84YZ2dCPei— a CloudFormation-generated physical ID. Recreating that stack changes the suffix and breaks every customer's trust policy simultaneously. A TTL that deprovisions on assume-role failure would then forget the entire customer base because we redeployed. Never act on a single failure; explicitly refuse to act when all accounts fail at once. Same instinct as the sweep's existing "never delete against a partial live set" guard (main.go:987-992).2. We lose the ability to verify at the exact moment we would act. "Only the role is left, no other resources" requires a working assume-role to confirm —
DescribeInstancesis how we know the account is empty. Once the role is gone that check is impossible, so we would be deciding blind.win11-testis precisely this case: records present, emptiness unverifiable except by the weak inference that the IP stopped responding.Proposed policy
Grace, then narrow, then never silently delete what cannot be verified.
unreachable, stop counting it intoErrors. No deletion.{base36}we hold no credentials for: report, never delete. We cannot distinguish "abandoned" from "someone forgot to add this account toREAPER_ROLE_ARNS," and deleting in the second case breaks a working spore.KandNare the open decisions. Suggest K=6 (one hour) and N=30 days, both configurable.Suggested split
Two separable pieces; the first is worth doing regardless of how the policy lands.
ListResourceRecordSetsover the zone → distinct{base36}labels → decode (strconv.ParseUint(label, 36, 64), the inverse ofdns.EncodeAccountID) → diff against the configured account set → log + count the unmanaged ones. Report-only, no deletion. Small, safe, and unit-testable without AWS in the same style assweepableRecord. Would have surfaced4zlw3a1tthe day it appeared.status,lastSeenAt) + the state machine above. Touches the onboarding contract, so it deserves its own decision.Caveat on liveness tests
Checking AWS Organizations membership is not a general staleness test. BYOA accounts belong to customers and are legitimately outside our org.
AccountNotFoundExceptiononly proved staleness for4zlw3a1tbecause that happened to be one of our own test accounts.Filed from the #438 verification work; see #438 for the sweep's current safety contract.