@intentius/chant-lexicon-aws 0.45.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/dist/api/read-client.d.ts +10 -2
  2. package/dist/api/read-client.d.ts.map +1 -1
  3. package/dist/components/cloud-executor.d.ts.map +1 -1
  4. package/dist/composites/agentcore-agent.d.ts +26 -1
  5. package/dist/composites/agentcore-agent.d.ts.map +1 -1
  6. package/dist/composites/bucket-deployment.d.ts +82 -0
  7. package/dist/composites/bucket-deployment.d.ts.map +1 -0
  8. package/dist/composites/dynamodb-table.d.ts +45 -0
  9. package/dist/composites/dynamodb-table.d.ts.map +1 -0
  10. package/dist/composites/ec2-instance-bundle.d.ts +61 -0
  11. package/dist/composites/ec2-instance-bundle.d.ts.map +1 -0
  12. package/dist/composites/ecr-repository.d.ts +49 -0
  13. package/dist/composites/ecr-repository.d.ts.map +1 -0
  14. package/dist/composites/fargate-service.d.ts +16 -0
  15. package/dist/composites/fargate-service.d.ts.map +1 -1
  16. package/dist/composites/index.d.ts +16 -2
  17. package/dist/composites/index.d.ts.map +1 -1
  18. package/dist/composites/lambda-function.d.ts +4 -4
  19. package/dist/composites/lambda-function.d.ts.map +1 -1
  20. package/dist/composites/monitoring-stack.d.ts +99 -0
  21. package/dist/composites/monitoring-stack.d.ts.map +1 -0
  22. package/dist/composites/nlb-service.d.ts +48 -0
  23. package/dist/composites/nlb-service.d.ts.map +1 -0
  24. package/dist/composites/step-functions-workflow.d.ts +36 -0
  25. package/dist/composites/step-functions-workflow.d.ts.map +1 -0
  26. package/dist/deep-observe.d.ts +45 -0
  27. package/dist/deep-observe.d.ts.map +1 -1
  28. package/dist/deep-topology.d.ts +104 -0
  29. package/dist/deep-topology.d.ts.map +1 -0
  30. package/dist/effect-receipt-row.d.ts +92 -0
  31. package/dist/effect-receipt-row.d.ts.map +1 -0
  32. package/dist/index.d.ts +6 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/integrity.json +9 -4
  35. package/dist/lint/audit-catalog.d.ts.map +1 -1
  36. package/dist/lint/post-synth/cf-refs.d.ts +24 -0
  37. package/dist/lint/post-synth/cf-refs.d.ts.map +1 -1
  38. package/dist/lint/post-synth/index.d.ts.map +1 -1
  39. package/dist/lint/post-synth/waw059.d.ts +36 -0
  40. package/dist/lint/post-synth/waw059.d.ts.map +1 -0
  41. package/dist/lint/post-synth/waw060.d.ts +16 -0
  42. package/dist/lint/post-synth/waw060.d.ts.map +1 -0
  43. package/dist/lint/post-synth/waw061.d.ts +19 -0
  44. package/dist/lint/post-synth/waw061.d.ts.map +1 -0
  45. package/dist/lint/post-synth/waw062.d.ts +19 -0
  46. package/dist/lint/post-synth/waw062.d.ts.map +1 -0
  47. package/dist/lint/post-synth/waw063.d.ts +35 -0
  48. package/dist/lint/post-synth/waw063.d.ts.map +1 -0
  49. package/dist/manifest.json +1 -1
  50. package/dist/okf/index.md +5 -0
  51. package/dist/okf/rules/WAW059.md +25 -0
  52. package/dist/okf/rules/WAW060.md +17 -0
  53. package/dist/okf/rules/WAW061.md +17 -0
  54. package/dist/okf/rules/WAW062.md +27 -0
  55. package/dist/okf/rules/WAW063.md +19 -0
  56. package/dist/okf/types/Action.md +2 -0
  57. package/dist/okf/types/AutoScalingGroup.md +1 -0
  58. package/dist/okf/types/Bucket.md +2 -0
  59. package/dist/okf/types/ECRRepository.md +1 -0
  60. package/dist/okf/types/Function.md +1 -0
  61. package/dist/okf/types/GlobalTable.md +4 -0
  62. package/dist/okf/types/IamPolicy.md +3 -0
  63. package/dist/okf/types/InstanceProfile.md +4 -0
  64. package/dist/okf/types/LaunchTemplate.md +1 -0
  65. package/dist/okf/types/LoadBalancer.md +1 -0
  66. package/dist/okf/types/ManagedPolicy.md +4 -0
  67. package/dist/okf/types/Map.md +2 -0
  68. package/dist/okf/types/Queue.md +1 -0
  69. package/dist/okf/types/Role.md +3 -0
  70. package/dist/okf/types/SecurityGroup.md +1 -0
  71. package/dist/okf/types/StateMachine.md +4 -0
  72. package/dist/okf/types/Subnet.md +4 -0
  73. package/dist/okf/types/Table.md +2 -0
  74. package/dist/okf/types/Type.md +5 -0
  75. package/dist/okf/types/Vpc.md +1 -0
  76. package/dist/op/activities/aws-apply.d.ts +48 -5
  77. package/dist/op/activities/aws-apply.d.ts.map +1 -1
  78. package/dist/op/activities/index.d.ts +11 -4
  79. package/dist/op/activities/index.d.ts.map +1 -1
  80. package/dist/ownership.d.ts +18 -0
  81. package/dist/ownership.d.ts.map +1 -1
  82. package/dist/plugin.d.ts.map +1 -1
  83. package/dist/receipt-store.d.ts +109 -0
  84. package/dist/receipt-store.d.ts.map +1 -0
  85. package/dist/rules/cf-refs.ts +62 -0
  86. package/dist/rules/waw059.ts +353 -0
  87. package/dist/rules/waw060.ts +91 -0
  88. package/dist/rules/waw061.ts +67 -0
  89. package/dist/rules/waw062.ts +0 -0
  90. package/dist/rules/waw063.ts +0 -0
  91. package/dist/serializer.d.ts.map +1 -1
  92. package/dist/skills/chant-aws-carve-terraform.md +26 -23
  93. package/dist/teardown.d.ts +85 -0
  94. package/dist/teardown.d.ts.map +1 -0
  95. package/package.json +3 -3
  96. package/src/api/read-client.test.ts +9 -0
  97. package/src/api/read-client.ts +15 -2
  98. package/src/components/cloud-executor.ts +10 -1
  99. package/src/composites/agentcore-agent.ts +35 -13
  100. package/src/composites/bucket-deployment.test.ts +84 -0
  101. package/src/composites/bucket-deployment.ts +153 -0
  102. package/src/composites/composites.test.ts +325 -0
  103. package/src/composites/dynamodb-table.test.ts +154 -0
  104. package/src/composites/dynamodb-table.ts +124 -0
  105. package/src/composites/ec2-instance-bundle.ts +134 -0
  106. package/src/composites/ecr-repository.test.ts +118 -0
  107. package/src/composites/ecr-repository.ts +117 -0
  108. package/src/composites/fargate-service.ts +19 -3
  109. package/src/composites/index.ts +21 -2
  110. package/src/composites/monitoring-stack.test.ts +130 -0
  111. package/src/composites/monitoring-stack.ts +164 -0
  112. package/src/composites/nlb-service.ts +117 -0
  113. package/src/composites/step-functions-workflow.ts +127 -0
  114. package/src/deep-observe.test.ts +201 -1
  115. package/src/deep-observe.ts +204 -0
  116. package/src/deep-topology.test.ts +392 -0
  117. package/src/deep-topology.ts +237 -0
  118. package/src/effect-receipt-row.test.ts +164 -0
  119. package/src/effect-receipt-row.ts +147 -0
  120. package/src/index.ts +28 -7
  121. package/src/lifecycle-integration.test.ts +4 -0
  122. package/src/lint/audit-catalog.ts +11 -0
  123. package/src/lint/post-synth/cf-refs.ts +62 -0
  124. package/src/lint/post-synth/index.ts +10 -0
  125. package/src/lint/post-synth/waw059.test.ts +309 -0
  126. package/src/lint/post-synth/waw059.ts +353 -0
  127. package/src/lint/post-synth/waw060.test.ts +131 -0
  128. package/src/lint/post-synth/waw060.ts +91 -0
  129. package/src/lint/post-synth/waw061.test.ts +99 -0
  130. package/src/lint/post-synth/waw061.ts +67 -0
  131. package/src/lint/post-synth/waw062.test.ts +109 -0
  132. package/src/lint/post-synth/waw062.ts +0 -0
  133. package/src/lint/post-synth/waw063.test.ts +220 -0
  134. package/src/lint/post-synth/waw063.ts +0 -0
  135. package/src/op/activities/aws-apply.test.ts +111 -0
  136. package/src/op/activities/aws-apply.ts +100 -6
  137. package/src/op/activities/index.ts +20 -3
  138. package/src/ownership.test.ts +24 -1
  139. package/src/ownership.ts +37 -0
  140. package/src/plugin.ts +59 -10
  141. package/src/receipt-store.test.ts +204 -0
  142. package/src/receipt-store.ts +315 -0
  143. package/src/serializer-ownership.test.ts +18 -0
  144. package/src/serializer.ts +114 -3
  145. package/src/skills/chant-aws-carve-terraform.md +26 -23
  146. package/src/teardown.test.ts +258 -0
  147. package/src/teardown.ts +276 -0
@@ -25,9 +25,10 @@ const {
25
25
  awsDeepNormalizationHooks,
26
26
  hasOwnershipMarker,
27
27
  schemaReadOnlyPatterns,
28
+ outOfBandChildName,
28
29
  } = await import("./deep-observe");
29
30
  const { parseResourceDescription } = await import("./api/read-client");
30
- const { deepDiffForLexicon } = await import("@intentius/chant/lifecycle/deep-observe");
31
+ const { deepDiffForLexicon, diffDeepObservation } = await import("@intentius/chant/lifecycle/deep-observe");
31
32
  const { normalizeDeepObservation, normalizeDeepProperties, flattenDeepProperties } = await import("@intentius/chant/deep-observation");
32
33
 
33
34
  const ok = (text: string) => ({ status: 200, text });
@@ -598,6 +599,205 @@ describe("observeResourcesDeepAws", () => {
598
599
  });
599
600
  });
600
601
 
602
+ /**
603
+ * The out-of-band pass (#1015): child resources that exist live under a
604
+ * declared parent and correspond to nothing in the stack, in the estate, or
605
+ * in source — the console-added SNS subscription and inline role policy the
606
+ * issue names. Cloud Control's `ListResources` is scoped to one parent by
607
+ * `ResourceModel`, and everything the listing returns is sorted into
608
+ * declared or out-of-band.
609
+ */
610
+ describe("out-of-band children (#1015)", () => {
611
+ /** A Cloud Control `ListResources` body — each model a JSON string, like `GetResource`. */
612
+ const list = (descriptions: Array<[string, Record<string, unknown>]>) =>
613
+ ok(
614
+ JSON.stringify({
615
+ ResourceDescriptions: descriptions.map(([id, props]) => ({
616
+ Identifier: id,
617
+ Properties: JSON.stringify(props),
618
+ })),
619
+ }),
620
+ );
621
+
622
+ test("a console-added inline policy surfaces as an entity nobody declared", async () => {
623
+ const fake = httpFake((_identifier, call) => {
624
+ if (!call.target) {
625
+ return stackResources([
626
+ ["AppRole", "AWS::IAM::Role", "app-role"],
627
+ // The stack's own inline policy, deployed under its Cloud Control
628
+ // identifier (`PolicyName|RoleName`, the schema's own order).
629
+ ["Inline", "AWS::IAM::RolePolicy", "declared-pol|app-role"],
630
+ ]);
631
+ }
632
+ if (call.target === "CloudApiService.ListResources") {
633
+ return list([
634
+ ["declared-pol|app-role", { RoleName: "app-role", PolicyName: "declared-pol" }],
635
+ [
636
+ "debug-access|app-role",
637
+ {
638
+ RoleName: "app-role",
639
+ PolicyName: "debug-access",
640
+ PolicyDocument: { Statement: [{ Sid: "Debug", Effect: "Allow", Action: ["s3:*"], Resource: "*" }] },
641
+ },
642
+ ],
643
+ ]);
644
+ }
645
+ return cloudControl("app-role", { RoleName: "app-role" });
646
+ });
647
+ const result = normalizeDeepObservation(
648
+ await observeResourcesDeepAws({ environment: "prod", entityNames: ["AppRole", "Inline"], http: fake.http }),
649
+ );
650
+
651
+ const name = outOfBandChildName("AWS::IAM::RolePolicy", "debug-access|app-role");
652
+ expect(result.resources[name]).toMatchObject({
653
+ type: "AWS::IAM::RolePolicy",
654
+ physicalId: "debug-access|app-role",
655
+ });
656
+ expect(result.resources[name].properties.PolicyName).toBe("debug-access");
657
+ // The stack's own inline policy is declared, not a finding.
658
+ expect(Object.keys(result.resources)).not.toContain(
659
+ outOfBandChildName("AWS::IAM::RolePolicy", "declared-pol|app-role"),
660
+ );
661
+ // The listing was scoped to the parent the stack read resolved.
662
+ const listing = fake.calls.find((c) => c.target === "CloudApiService.ListResources");
663
+ expect(JSON.parse(listing?.body ?? "{}")).toEqual({
664
+ TypeName: "AWS::IAM::RolePolicy",
665
+ ResourceModel: JSON.stringify({ RoleName: "app-role" }),
666
+ });
667
+ });
668
+
669
+ test("a console-added SNS subscription surfaces even though the topic has no deep reader", async () => {
670
+ const topicArn = "arn:aws:sns:us-east-1:111122223333:alerts";
671
+ const fake = httpFake((_identifier, call) => {
672
+ if (!call.target) {
673
+ return stackResources([
674
+ ["Alerts", "AWS::SNS::Topic", topicArn],
675
+ ["Mail", "AWS::SNS::Subscription", `${topicArn}:5d5a2c73`],
676
+ ]);
677
+ }
678
+ if (call.target === "CloudApiService.ListResources") {
679
+ return list([
680
+ [`${topicArn}:5d5a2c73`, { TopicArn: topicArn, Protocol: "email" }],
681
+ [`${topicArn}:rogue`, { TopicArn: topicArn, Protocol: "https", Endpoint: "https://n.example" }],
682
+ ]);
683
+ }
684
+ return apiError("ValidationException", "nothing else should be read");
685
+ });
686
+ const result = normalizeDeepObservation(
687
+ await observeResourcesDeepAws({ environment: "prod", entityNames: ["Alerts", "Mail"], http: fake.http }),
688
+ );
689
+
690
+ // The topic itself stays a hole — no deep reader — but the listing under
691
+ // it needs only the physical id the stack already resolved.
692
+ expect(result.unobserved.Alerts?.reason).toBe("unsupported-kind");
693
+ expect(Object.keys(result.resources)).toEqual([
694
+ outOfBandChildName("AWS::SNS::Subscription", `${topicArn}:rogue`),
695
+ ]);
696
+ });
697
+
698
+ test("a legacy AWS::IAM::Policy attachment is declared, not out-of-band", async () => {
699
+ const fake = httpFake((_identifier, call) => {
700
+ if (!call.target) {
701
+ return stackResources([
702
+ ["AppRole", "AWS::IAM::Role", "app-role"],
703
+ // The legacy inline form: its physical id is the policy name, which
704
+ // is the first part of the RolePolicy identifier the listing returns.
705
+ ["LegacyPol", "AWS::IAM::Policy", "stack-legacypol-1ABC"],
706
+ ]);
707
+ }
708
+ if (call.target === "CloudApiService.ListResources") {
709
+ return list([["stack-legacypol-1ABC|app-role", { RoleName: "app-role", PolicyName: "stack-legacypol-1ABC" }]]);
710
+ }
711
+ return cloudControl("app-role", { RoleName: "app-role" });
712
+ });
713
+ const result = normalizeDeepObservation(
714
+ await observeResourcesDeepAws({ environment: "prod", entityNames: ["AppRole", "LegacyPol"], http: fake.http }),
715
+ );
716
+ expect(Object.keys(result.resources)).toEqual(["AppRole"]);
717
+ });
718
+
719
+ test("an identity spelled in source counts as declared before it reaches any stack", async () => {
720
+ // The carve case: the policy is declared, its identity is spelled in
721
+ // props, and no stack carries it yet. Listing it is not a finding.
722
+ const declared = entities({
723
+ AppRole: { entityType: "AWS::IAM::Role", props: { RoleName: "app-role" } },
724
+ Carved: { entityType: "AWS::IAM::RolePolicy", props: { RoleName: "app-role", PolicyName: "carved" } },
725
+ });
726
+ const fake = httpFake((_identifier, call) => {
727
+ if (!call.target) return stackResources([["AppRole", "AWS::IAM::Role", "app-role"]]);
728
+ if (call.target === "CloudApiService.ListResources") {
729
+ return list([["carved|app-role", { RoleName: "app-role", PolicyName: "carved" }]]);
730
+ }
731
+ return cloudControl("app-role", { RoleName: "app-role" });
732
+ });
733
+ const result = normalizeDeepObservation(
734
+ await observeResourcesDeepAws({
735
+ environment: "prod",
736
+ entityNames: ["AppRole", "Carved"],
737
+ entities: declared,
738
+ http: fake.http,
739
+ }),
740
+ );
741
+ expect(Object.keys(result.resources)).toEqual(["AppRole"]);
742
+ });
743
+
744
+ test("a failed child listing is silence about children, never a fabricated finding", async () => {
745
+ const fake = httpFake((_identifier, call) => {
746
+ if (!call.target) return stackResources([["AppRole", "AWS::IAM::Role", "app-role"]]);
747
+ if (call.target === "CloudApiService.ListResources") {
748
+ // Floci's answer for an operation it does not serve.
749
+ return apiError("UnsupportedOperation", "ListResources is not supported for AWS::IAM::RolePolicy");
750
+ }
751
+ return cloudControl("app-role", { RoleName: "app-role" });
752
+ });
753
+ const result = normalizeDeepObservation(
754
+ await observeResourcesDeepAws({ environment: "prod", entityNames: ["AppRole"], http: fake.http }),
755
+ );
756
+ expect(Object.keys(result.resources)).toEqual(["AppRole"]);
757
+ expect(result.unobserved).toEqual({});
758
+ });
759
+
760
+ test("--owned: a withheld parent keeps its children out of the report too", async () => {
761
+ const fake = httpFake((_identifier, call) => {
762
+ if (!call.target) return stackResources([["AppRole", "AWS::IAM::Role", "app-role"]]);
763
+ if (call.target === "CloudApiService.ListResources") {
764
+ return list([["debug-access|app-role", { RoleName: "app-role", PolicyName: "debug-access" }]]);
765
+ }
766
+ // No ownership tag: the parent is not chant's.
767
+ return cloudControl("app-role", { RoleName: "app-role" });
768
+ });
769
+ const result = normalizeDeepObservation(
770
+ await observeResourcesDeepAws({ environment: "prod", entityNames: ["AppRole"], owned: true, http: fake.http }),
771
+ );
772
+ expect(result.unobserved.AppRole?.reason).toBe("filtered");
773
+ expect(Object.keys(result.resources)).toEqual([]);
774
+ expect(fake.calls.some((c) => c.target === "CloudApiService.ListResources")).toBe(false);
775
+ });
776
+
777
+ test("the deep diff reports the child as an undeclared entity, and the declared estate stays clean", async () => {
778
+ const declared = entities({ AppRole: { entityType: "AWS::IAM::Role", props: { RoleName: "app-role" } } });
779
+ const fake = httpFake((_identifier, call) => {
780
+ if (!call.target) return stackResources([["AppRole", "AWS::IAM::Role", "app-role"]]);
781
+ if (call.target === "CloudApiService.ListResources") {
782
+ return list([["debug-access|app-role", { RoleName: "app-role", PolicyName: "debug-access" }]]);
783
+ }
784
+ return cloudControl("app-role", { RoleName: "app-role" });
785
+ });
786
+ const live = normalizeDeepObservation(
787
+ await observeResourcesDeepAws({
788
+ environment: "prod",
789
+ entityNames: ["AppRole"],
790
+ entities: declared,
791
+ http: fake.http,
792
+ }),
793
+ );
794
+ const result = diffDeepObservation(declared, live, awsDeepNormalizationHooks);
795
+ expect(result.undeclaredEntities).toEqual([outOfBandChildName("AWS::IAM::RolePolicy", "debug-access|app-role")]);
796
+ expect(result.unchanged).toEqual(["AppRole"]);
797
+ expect(result.drifted).toEqual([]);
798
+ });
799
+ });
800
+
601
801
  /**
602
802
  * The acceptance test for #1015: the real plugin, a mutated live tree, a
603
803
  * baseline, and exactly the genuine drift.
@@ -56,10 +56,19 @@ import {
56
56
  AwsReadError,
57
57
  describeStackResources,
58
58
  getResource,
59
+ listResources,
59
60
  type AwsReadClientOptions,
60
61
  type AwsReadHttp,
62
+ type StackResource,
61
63
  } from "./api/read-client";
64
+ import { declaredIdentifier } from "./identity-observe";
62
65
  import { AWS_TAG_OWNERSHIP_KEYS } from "./ownership";
66
+ import {
67
+ AWS_DEEP_BLIND_SPOTS,
68
+ EC2_TOPOLOGY_GENERATED_NAMES,
69
+ EC2_TOPOLOGY_SERVICE_DEFAULTS,
70
+ EC2_TOPOLOGY_SOURCES,
71
+ } from "./deep-topology";
63
72
  import { applyAwsEndpointArgv } from "./components/cloud-executor";
64
73
  import { toIngressRules } from "./dependencies";
65
74
  import { createRequire } from "node:module";
@@ -106,6 +115,9 @@ export const DEEP_SOURCES: Record<string, DeepSource> = {
106
115
  id: "GroupId",
107
116
  toModel: securityGroupToModel,
108
117
  },
118
+ // The EC2 topology types the folds fold over (#1269), sources and
119
+ // translations alongside their type in ./deep-topology.ts.
120
+ ...EC2_TOPOLOGY_SOURCES,
109
121
  };
110
122
 
111
123
  /**
@@ -114,6 +126,74 @@ export const DEEP_SOURCES: Record<string, DeepSource> = {
114
126
  */
115
127
  export const DEEP_READABLE_TYPES: ReadonlySet<string> = new Set(Object.keys(DEEP_SOURCES));
116
128
 
129
+ /**
130
+ * A child type this reader enumerates under one declared parent (#1015's
131
+ * out-of-band case) — the console-added SNS subscription or inline role
132
+ * policy that no property diff can see, because it is not a property of
133
+ * anything declared: it is a whole resource CloudFormation never made.
134
+ *
135
+ * Cloud Control's `ListResources` takes the parent as a `ResourceModel`
136
+ * scope, which is what `model` builds from the physical id the stack read
137
+ * already resolved. Every listed child is then sorted into declared (it is
138
+ * some stack resource's physical id, or some declared entity's spelled
139
+ * identity) or out-of-band; the out-of-band ones ride into the observation
140
+ * as live resources nobody declared, which the deep diff reports as
141
+ * undeclared entities.
142
+ */
143
+ export interface DeepChildSource {
144
+ /** The child's CloudFormation type — what `ListResources` is asked for. */
145
+ childType: string;
146
+ /** The `ResourceModel` scoping the listing to one parent. */
147
+ model: (parentPhysicalId: string) => Record<string, unknown>;
148
+ /**
149
+ * Legacy declared forms of the same live child. `AWS::IAM::Policy` attached
150
+ * to a role creates the same inline policy `AWS::IAM::RolePolicy` lists, but
151
+ * under its own physical id — the policy name, which is the second part of
152
+ * the RolePolicy identifier. Without this leg every legacy inline policy
153
+ * reads as out-of-band on a stack that declared it.
154
+ */
155
+ declaredVia?: Array<{
156
+ type: string;
157
+ /** The part of the child identifier that carries that type's physical id. */
158
+ part: (identifier: string) => string | undefined;
159
+ }>;
160
+ }
161
+
162
+ /**
163
+ * Parent type → the child types worth listing under it. Scoped like
164
+ * {@link DEEP_SOURCES}: the high-signal cases first — the issue's own
165
+ * examples — and widened per type, not by wildcard. A child type Cloud
166
+ * Control cannot list parent-scoped does not belong here.
167
+ */
168
+ export const DEEP_CHILD_SOURCES: Record<string, readonly DeepChildSource[]> = {
169
+ "AWS::IAM::Role": [
170
+ {
171
+ childType: "AWS::IAM::RolePolicy",
172
+ model: (roleName) => ({ RoleName: roleName }),
173
+ // A RolePolicy identifier is `PolicyName|RoleName` — the schema's own
174
+ // primaryIdentifier order, which is what the manifest records and what
175
+ // `declaredIdentifier` joins by. The legacy type's physical id is the
176
+ // policy name, the first part.
177
+ declaredVia: [{ type: "AWS::IAM::Policy", part: (id) => id.split("|")[0] }],
178
+ },
179
+ ],
180
+ "AWS::SNS::Topic": [
181
+ {
182
+ childType: "AWS::SNS::Subscription",
183
+ model: (topicArn) => ({ TopicArn: topicArn }),
184
+ },
185
+ ],
186
+ };
187
+
188
+ /**
189
+ * The name an out-of-band child reports under. There is no chant entity name
190
+ * to use — nobody declared it — so the name has to carry the finding on its
191
+ * own: what kind of thing, and which one.
192
+ */
193
+ export function outOfBandChildName(childType: string, identifier: string): string {
194
+ return `${childType}:${identifier}`;
195
+ }
196
+
117
197
  /**
118
198
  * `describe-security-groups` -> the `AWS::EC2::SecurityGroup` resource model.
119
199
  *
@@ -205,6 +285,7 @@ export const AWS_SERVICE_DEFAULTS: Record<string, Record<string, unknown>> = {
205
285
  "SecurityGroupEgress[].CidrIp": "0.0.0.0/0",
206
286
  "SecurityGroupEgress[].IpProtocol": "-1",
207
287
  },
288
+ ...EC2_TOPOLOGY_SERVICE_DEFAULTS,
208
289
  };
209
290
 
210
291
  /**
@@ -218,6 +299,7 @@ export const AWS_SERVICE_DEFAULTS: Record<string, Record<string, unknown>> = {
218
299
  */
219
300
  export const AWS_GENERATED_NAMES: Record<string, ReadonlySet<string>> = {
220
301
  "AWS::EC2::SecurityGroup": new Set(["GroupName"]),
302
+ ...EC2_TOPOLOGY_GENERATED_NAMES,
221
303
  };
222
304
 
223
305
  /** Stable JSON with sorted keys — the fallback ordering key for a set-like array. */
@@ -303,6 +385,18 @@ export const awsDeepNormalizationHooks: DeepNormalizationHooks = {
303
385
  if (schemaReadOnlyPatterns(node.entityType).has(node.pattern)) return true;
304
386
  if (AWS_READ_ONLY_NAMES.has(lastSegment(node.pattern))) return true;
305
387
 
388
+ // A declared property this type's source cannot see (#1269). With nothing
389
+ // live to stand against it would report `value -> <absent>` on every clean
390
+ // apply — a blind spot rendered as drift. Counterpart-gated the other way
391
+ // round: a live tree that does carry the field is still compared.
392
+ if (
393
+ node.side === "declared" &&
394
+ node.counterpart === "absent" &&
395
+ AWS_DEEP_BLIND_SPOTS[node.entityType]?.has(node.pattern)
396
+ ) {
397
+ return true;
398
+ }
399
+
306
400
  // Provider defaults, on the live side only, and only where source is silent
307
401
  // about the property. `"unknown"` (a one-sided normalization) never prunes:
308
402
  // the reader must not decide this before the declared tree is in hand.
@@ -606,9 +700,119 @@ export async function observeResourcesDeepAws(
606
700
  };
607
701
  });
608
702
 
703
+ // The out-of-band pass (#1015): under each declared parent whose type names
704
+ // child sources, list what actually exists and keep what nothing declared.
705
+ // A parent `--owned` withheld keeps its children out too — reporting a
706
+ // foreign resource's children while withholding the resource itself would
707
+ // be the filter half-applied. A parent with no deep reader of its own
708
+ // (`unsupported-kind`) still gets its children enumerated: listing under it
709
+ // needs only the physical id the stack already resolved.
710
+ const parents: Array<{ resource: StackResource; sources: readonly DeepChildSource[] }> = [];
711
+ for (const entityName of options.entityNames) {
712
+ const resource = byLogicalId.get(entityName);
713
+ const sources = resource ? DEEP_CHILD_SOURCES[resource.type] : undefined;
714
+ if (!resource?.physicalId || !sources) continue;
715
+ if (unobserved[entityName]?.reason === "filtered") continue;
716
+ parents.push({ resource, sources });
717
+ }
718
+ const children = await enumerateOutOfBandChildren({
719
+ parents,
720
+ allStackResources: stackResources,
721
+ entities: options.entities,
722
+ client,
723
+ normalize: (childType, properties) =>
724
+ normalizeDeepProperties(properties, {
725
+ entityType: childType,
726
+ side: "live",
727
+ hooks: awsDeepNormalizationHooks,
728
+ }),
729
+ concurrently: boundedConcurrently,
730
+ });
731
+ for (const [name, child] of Object.entries(children)) {
732
+ if (!resources[name]) resources[name] = child;
733
+ }
734
+
609
735
  return deepObservation(resources, unobserved);
610
736
  }
611
737
 
738
+ /**
739
+ * List every child-sourced type under its declared parents and return the
740
+ * resources nothing declared, named by {@link outOfBandChildName}.
741
+ *
742
+ * "Declared" is answered three ways, in order of how directly the stack says
743
+ * it: the child identifier is some stack resource's physical id (the normal
744
+ * case — a declared `AWS::SNS::Subscription` deploys under its ARN); a
745
+ * `declaredVia` legacy form's physical id is the identifier's relevant part;
746
+ * or a declared entity's props spell the identifier (`declaredIdentifier`,
747
+ * the carve case — declared in source, not yet in any stack).
748
+ *
749
+ * A listing that fails is silence about that parent's children, never a
750
+ * finding and never a hole: there is no declared entity to hang a hole on,
751
+ * and fabricating one would put an undeclared name in the unobserved report.
752
+ * `UnsupportedOperation` — an emulator without parent-scoped listing — lands
753
+ * here too, which is what keeps the Floci lanes quiet.
754
+ */
755
+ async function enumerateOutOfBandChildren(args: {
756
+ parents: Array<{ resource: StackResource; sources: readonly DeepChildSource[] }>;
757
+ allStackResources: StackResource[];
758
+ entities?: Map<string, { entityType: string; props: Record<string, unknown> }>;
759
+ client: AwsReadClientOptions;
760
+ normalize: (childType: string, properties: Record<string, unknown>) => Record<string, unknown>;
761
+ concurrently: <T>(items: T[], fn: (item: T) => Promise<void>) => Promise<unknown>;
762
+ }): Promise<Record<string, DeepResourceObservation>> {
763
+ const out: Record<string, DeepResourceObservation> = {};
764
+ if (args.parents.length === 0) return out;
765
+
766
+ // Physical ids by type, over the WHOLE stack — a stack resource that is not
767
+ // a declared chant entity (a nested stack's row, a resource another tool
768
+ // added to the same stack) is still stack-managed, not out-of-band.
769
+ const addTo = (map: Map<string, Set<string>>, key: string, value: string) => {
770
+ const set = map.get(key) ?? new Set<string>();
771
+ set.add(value);
772
+ map.set(key, set);
773
+ };
774
+ const stackIdsByType = new Map<string, Set<string>>();
775
+ for (const r of args.allStackResources) {
776
+ if (r.physicalId) addTo(stackIdsByType, r.type, r.physicalId);
777
+ }
778
+ const declaredIdsByType = new Map<string, Set<string>>();
779
+ for (const entity of args.entities?.values() ?? []) {
780
+ const id = declaredIdentifier(entity.entityType, entity.props ?? {});
781
+ if (id) addTo(declaredIdsByType, entity.entityType, id);
782
+ }
783
+
784
+ const isDeclared = (source: DeepChildSource, identifier: string): boolean => {
785
+ if (stackIdsByType.get(source.childType)?.has(identifier)) return true;
786
+ if (declaredIdsByType.get(source.childType)?.has(identifier)) return true;
787
+ for (const via of source.declaredVia ?? []) {
788
+ const part = via.part(identifier);
789
+ if (part && stackIdsByType.get(via.type)?.has(part)) return true;
790
+ }
791
+ return false;
792
+ };
793
+
794
+ const listings = args.parents.flatMap(({ resource, sources }) =>
795
+ sources.map((source) => ({ physicalId: resource.physicalId as string, source })),
796
+ );
797
+ await args.concurrently(listings, async ({ physicalId, source }) => {
798
+ let listed;
799
+ try {
800
+ listed = await listResources(source.childType, args.client, source.model(physicalId));
801
+ } catch {
802
+ return;
803
+ }
804
+ for (const child of listed) {
805
+ if (!child.identifier || isDeclared(source, child.identifier)) continue;
806
+ out[outOfBandChildName(source.childType, child.identifier)] = {
807
+ type: source.childType,
808
+ physicalId: child.identifier,
809
+ properties: args.normalize(source.childType, child.properties),
810
+ };
811
+ }
812
+ });
813
+ return out;
814
+ }
815
+
612
816
  /**
613
817
  * Read every `ec2`-sourced type in bulk, keyed by physical id.
614
818
  *