@intentius/chant-lexicon-aws 0.46.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 (114) 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/composites/agentcore-agent.d.ts +26 -1
  4. package/dist/composites/agentcore-agent.d.ts.map +1 -1
  5. package/dist/composites/bucket-deployment.d.ts +82 -0
  6. package/dist/composites/bucket-deployment.d.ts.map +1 -0
  7. package/dist/composites/dynamodb-table.d.ts +45 -0
  8. package/dist/composites/dynamodb-table.d.ts.map +1 -0
  9. package/dist/composites/ec2-instance-bundle.d.ts +61 -0
  10. package/dist/composites/ec2-instance-bundle.d.ts.map +1 -0
  11. package/dist/composites/ecr-repository.d.ts +49 -0
  12. package/dist/composites/ecr-repository.d.ts.map +1 -0
  13. package/dist/composites/fargate-service.d.ts +16 -0
  14. package/dist/composites/fargate-service.d.ts.map +1 -1
  15. package/dist/composites/index.d.ts +16 -2
  16. package/dist/composites/index.d.ts.map +1 -1
  17. package/dist/composites/monitoring-stack.d.ts +99 -0
  18. package/dist/composites/monitoring-stack.d.ts.map +1 -0
  19. package/dist/composites/nlb-service.d.ts +48 -0
  20. package/dist/composites/nlb-service.d.ts.map +1 -0
  21. package/dist/composites/step-functions-workflow.d.ts +36 -0
  22. package/dist/composites/step-functions-workflow.d.ts.map +1 -0
  23. package/dist/deep-observe.d.ts +45 -0
  24. package/dist/deep-observe.d.ts.map +1 -1
  25. package/dist/deep-topology.d.ts +104 -0
  26. package/dist/deep-topology.d.ts.map +1 -0
  27. package/dist/effect-receipt-row.d.ts +92 -0
  28. package/dist/effect-receipt-row.d.ts.map +1 -0
  29. package/dist/index.d.ts +6 -2
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/integrity.json +7 -4
  32. package/dist/lint/audit-catalog.d.ts.map +1 -1
  33. package/dist/lint/post-synth/cf-refs.d.ts +17 -0
  34. package/dist/lint/post-synth/cf-refs.d.ts.map +1 -1
  35. package/dist/lint/post-synth/index.d.ts.map +1 -1
  36. package/dist/lint/post-synth/waw061.d.ts +19 -0
  37. package/dist/lint/post-synth/waw061.d.ts.map +1 -0
  38. package/dist/lint/post-synth/waw062.d.ts +19 -0
  39. package/dist/lint/post-synth/waw062.d.ts.map +1 -0
  40. package/dist/lint/post-synth/waw063.d.ts +35 -0
  41. package/dist/lint/post-synth/waw063.d.ts.map +1 -0
  42. package/dist/manifest.json +1 -1
  43. package/dist/okf/index.md +3 -0
  44. package/dist/okf/rules/WAW061.md +17 -0
  45. package/dist/okf/rules/WAW062.md +27 -0
  46. package/dist/okf/rules/WAW063.md +19 -0
  47. package/dist/okf/types/Action.md +1 -0
  48. package/dist/okf/types/AutoScalingGroup.md +1 -0
  49. package/dist/okf/types/Bucket.md +1 -0
  50. package/dist/okf/types/ECRRepository.md +1 -0
  51. package/dist/okf/types/Function.md +1 -0
  52. package/dist/okf/types/IamPolicy.md +1 -0
  53. package/dist/okf/types/LaunchTemplate.md +1 -0
  54. package/dist/okf/types/LoadBalancer.md +1 -0
  55. package/dist/okf/types/ManagedPolicy.md +2 -0
  56. package/dist/okf/types/Map.md +1 -0
  57. package/dist/okf/types/Role.md +2 -0
  58. package/dist/okf/types/SecurityGroup.md +1 -0
  59. package/dist/okf/types/StateMachine.md +4 -0
  60. package/dist/okf/types/Subnet.md +4 -0
  61. package/dist/okf/types/Table.md +1 -0
  62. package/dist/okf/types/Type.md +3 -0
  63. package/dist/okf/types/Vpc.md +1 -0
  64. package/dist/op/activities/index.d.ts +6 -0
  65. package/dist/op/activities/index.d.ts.map +1 -1
  66. package/dist/plugin.d.ts.map +1 -1
  67. package/dist/receipt-store.d.ts +109 -0
  68. package/dist/receipt-store.d.ts.map +1 -0
  69. package/dist/rules/cf-refs.ts +40 -0
  70. package/dist/rules/waw061.ts +67 -0
  71. package/dist/rules/waw062.ts +0 -0
  72. package/dist/rules/waw063.ts +0 -0
  73. package/dist/serializer.d.ts.map +1 -1
  74. package/dist/skills/chant-aws-carve-terraform.md +26 -23
  75. package/package.json +3 -3
  76. package/src/api/read-client.test.ts +9 -0
  77. package/src/api/read-client.ts +15 -2
  78. package/src/composites/agentcore-agent.ts +35 -13
  79. package/src/composites/bucket-deployment.test.ts +84 -0
  80. package/src/composites/bucket-deployment.ts +153 -0
  81. package/src/composites/composites.test.ts +325 -0
  82. package/src/composites/dynamodb-table.test.ts +154 -0
  83. package/src/composites/dynamodb-table.ts +124 -0
  84. package/src/composites/ec2-instance-bundle.ts +134 -0
  85. package/src/composites/ecr-repository.test.ts +118 -0
  86. package/src/composites/ecr-repository.ts +117 -0
  87. package/src/composites/fargate-service.ts +19 -3
  88. package/src/composites/index.ts +21 -2
  89. package/src/composites/monitoring-stack.test.ts +130 -0
  90. package/src/composites/monitoring-stack.ts +164 -0
  91. package/src/composites/nlb-service.ts +117 -0
  92. package/src/composites/step-functions-workflow.ts +127 -0
  93. package/src/deep-observe.test.ts +201 -1
  94. package/src/deep-observe.ts +204 -0
  95. package/src/deep-topology.test.ts +392 -0
  96. package/src/deep-topology.ts +237 -0
  97. package/src/effect-receipt-row.test.ts +164 -0
  98. package/src/effect-receipt-row.ts +147 -0
  99. package/src/index.ts +28 -7
  100. package/src/lint/audit-catalog.ts +6 -0
  101. package/src/lint/post-synth/cf-refs.ts +40 -0
  102. package/src/lint/post-synth/index.ts +6 -0
  103. package/src/lint/post-synth/waw061.test.ts +99 -0
  104. package/src/lint/post-synth/waw061.ts +67 -0
  105. package/src/lint/post-synth/waw062.test.ts +109 -0
  106. package/src/lint/post-synth/waw062.ts +0 -0
  107. package/src/lint/post-synth/waw063.test.ts +220 -0
  108. package/src/lint/post-synth/waw063.ts +0 -0
  109. package/src/op/activities/index.ts +15 -0
  110. package/src/plugin.ts +41 -10
  111. package/src/receipt-store.test.ts +204 -0
  112. package/src/receipt-store.ts +315 -0
  113. package/src/serializer.ts +105 -2
  114. package/src/skills/chant-aws-carve-terraform.md +26 -23
@@ -0,0 +1,109 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { createPostSynthContext } from "@intentius/chant-test-utils";
3
+ import { waw062, checkDuplicateNamesAndExports } from "./waw062";
4
+
5
+ function makeCtx(template: object) {
6
+ return createPostSynthContext({ aws: template });
7
+ }
8
+
9
+ describe("WAW062: Duplicate Name Or Export Within A Template", () => {
10
+ test("check metadata", () => {
11
+ expect(waw062.id).toBe("WAW062");
12
+ expect(waw062.description.toLowerCase()).toContain("duplicate");
13
+ });
14
+
15
+ test("duplicate Export Name → error", () => {
16
+ const ctx = makeCtx({
17
+ Resources: {
18
+ MyBucket: { Type: "AWS::S3::Bucket", Properties: {} },
19
+ },
20
+ Outputs: {
21
+ BucketArnA: { Value: { "Fn::GetAtt": ["MyBucket", "Arn"] }, Export: { Name: "shared-bucket-arn" } },
22
+ BucketArnB: { Value: { "Fn::GetAtt": ["MyBucket", "Arn"] }, Export: { Name: "shared-bucket-arn" } },
23
+ },
24
+ });
25
+ const diags = checkDuplicateNamesAndExports(ctx);
26
+ expect(diags).toHaveLength(1);
27
+ expect(diags[0].checkId).toBe("WAW062");
28
+ expect(diags[0].severity).toBe("error");
29
+ expect(diags[0].message).toContain("shared-bucket-arn");
30
+ expect(diags[0].message).toContain("BucketArnA");
31
+ expect(diags[0].message).toContain("BucketArnB");
32
+ expect(diags[0].lexicon).toBe("aws");
33
+ });
34
+
35
+ test("unique Export Names → no diagnostic", () => {
36
+ const ctx = makeCtx({
37
+ Resources: {},
38
+ Outputs: {
39
+ A: { Value: "a", Export: { Name: "export-a" } },
40
+ B: { Value: "b", Export: { Name: "export-b" } },
41
+ },
42
+ });
43
+ expect(checkDuplicateNamesAndExports(ctx)).toHaveLength(0);
44
+ });
45
+
46
+ test("duplicate ECR RepositoryName across two composites → error", () => {
47
+ const ctx = makeCtx({
48
+ Resources: {
49
+ RepoOne: { Type: "AWS::ECR::Repository", Properties: { RepositoryName: "svc-images" } },
50
+ RepoTwo: { Type: "AWS::ECR::Repository", Properties: { RepositoryName: "svc-images" } },
51
+ },
52
+ });
53
+ const diags = checkDuplicateNamesAndExports(ctx);
54
+ expect(diags).toHaveLength(1);
55
+ expect(diags[0].message).toContain("svc-images");
56
+ expect(diags[0].message).toContain("RepoOne");
57
+ expect(diags[0].message).toContain("RepoTwo");
58
+ });
59
+
60
+ test("duplicate LaunchTemplateName → error", () => {
61
+ const ctx = makeCtx({
62
+ Resources: {
63
+ LtA: { Type: "AWS::EC2::LaunchTemplate", Properties: { LaunchTemplateName: "worker-lt" } },
64
+ LtB: { Type: "AWS::EC2::LaunchTemplate", Properties: { LaunchTemplateName: "worker-lt" } },
65
+ },
66
+ });
67
+ expect(checkDuplicateNamesAndExports(ctx)).toHaveLength(1);
68
+ });
69
+
70
+ test("duplicate SecurityGroup GroupName → error", () => {
71
+ const ctx = makeCtx({
72
+ Resources: {
73
+ SgA: { Type: "AWS::EC2::SecurityGroup", Properties: { GroupName: "web-sg", GroupDescription: "a" } },
74
+ SgB: { Type: "AWS::EC2::SecurityGroup", Properties: { GroupName: "web-sg", GroupDescription: "b" } },
75
+ },
76
+ });
77
+ expect(checkDuplicateNamesAndExports(ctx)).toHaveLength(1);
78
+ });
79
+
80
+ test("same literal name across different resource types → no diagnostic (not comparable)", () => {
81
+ const ctx = makeCtx({
82
+ Resources: {
83
+ RepoOne: { Type: "AWS::ECR::Repository", Properties: { RepositoryName: "shared" } },
84
+ TableOne: { Type: "AWS::DynamoDB::Table", Properties: { TableName: "shared" } },
85
+ },
86
+ });
87
+ expect(checkDuplicateNamesAndExports(ctx)).toHaveLength(0);
88
+ });
89
+
90
+ test("intrinsic explicit names → no diagnostic (unprovable)", () => {
91
+ const ctx = makeCtx({
92
+ Resources: {
93
+ RepoOne: { Type: "AWS::ECR::Repository", Properties: { RepositoryName: { "Fn::Sub": "svc-${AWS::StackName}" } } },
94
+ RepoTwo: { Type: "AWS::ECR::Repository", Properties: { RepositoryName: { "Fn::Sub": "svc-${AWS::StackName}" } } },
95
+ },
96
+ });
97
+ expect(checkDuplicateNamesAndExports(ctx)).toHaveLength(0);
98
+ });
99
+
100
+ test("distinct explicit names → no diagnostic", () => {
101
+ const ctx = makeCtx({
102
+ Resources: {
103
+ RepoOne: { Type: "AWS::ECR::Repository", Properties: { RepositoryName: "svc-images-a" } },
104
+ RepoTwo: { Type: "AWS::ECR::Repository", Properties: { RepositoryName: "svc-images-b" } },
105
+ },
106
+ });
107
+ expect(checkDuplicateNamesAndExports(ctx)).toHaveLength(0);
108
+ });
109
+ });
Binary file
@@ -0,0 +1,220 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { createPostSynthContext } from "@intentius/chant-test-utils";
3
+ import { waw063, checkDenyAllowContradiction } from "./waw063";
4
+
5
+ function makeCtx(template: object) {
6
+ return createPostSynthContext({ aws: template });
7
+ }
8
+
9
+ describe("WAW063: IAM Policy Denies An Action It Also Allows", () => {
10
+ test("check metadata", () => {
11
+ expect(waw063.id).toBe("WAW063");
12
+ expect(waw063.description).toContain("Deny");
13
+ });
14
+
15
+ test("guardrail policy denies exactly what a workflow policy allows on the same role → error", () => {
16
+ // Mirrors the issue's own example: a role used by a StepFunctions workflow
17
+ // needs ec2:ModifyInstanceAttribute; a separately-attached guardrail policy
18
+ // denies it. Deploy succeeds; the workflow 403s at runtime.
19
+ const ctx = makeCtx({
20
+ Resources: {
21
+ WorkflowRole: { Type: "AWS::IAM::Role", Properties: { AssumeRolePolicyDocument: { Statement: [] } } },
22
+ WorkflowPolicy: {
23
+ Type: "AWS::IAM::Policy",
24
+ Properties: {
25
+ PolicyName: "workflow-needs",
26
+ Roles: [{ Ref: "WorkflowRole" }],
27
+ PolicyDocument: {
28
+ Statement: [{ Effect: "Allow", Action: "ec2:ModifyInstanceAttribute", Resource: "*" }],
29
+ },
30
+ },
31
+ },
32
+ SecurityGuardrailPolicy: {
33
+ Type: "AWS::IAM::Policy",
34
+ Properties: {
35
+ PolicyName: "guardrail",
36
+ Roles: [{ Ref: "WorkflowRole" }],
37
+ PolicyDocument: {
38
+ Statement: [{ Effect: "Deny", Action: "ec2:ModifyInstanceAttribute", Resource: "*" }],
39
+ },
40
+ },
41
+ },
42
+ },
43
+ });
44
+ const diags = checkDenyAllowContradiction(ctx);
45
+ expect(diags).toHaveLength(1);
46
+ expect(diags[0].checkId).toBe("WAW063");
47
+ expect(diags[0].severity).toBe("error");
48
+ expect(diags[0].entity).toBe("WorkflowRole");
49
+ expect(diags[0].message).toContain("ec2:ModifyInstanceAttribute");
50
+ expect(diags[0].message).toContain("SecurityGuardrailPolicy");
51
+ expect(diags[0].message).toContain("WorkflowPolicy");
52
+ expect(diags[0].lexicon).toBe("aws");
53
+ });
54
+
55
+ test("wildcarded Deny nullifies a literal Allow on the same role's inline policy → error", () => {
56
+ const ctx = makeCtx({
57
+ Resources: {
58
+ AppRole: {
59
+ Type: "AWS::IAM::Role",
60
+ Properties: {
61
+ AssumeRolePolicyDocument: { Statement: [] },
62
+ Policies: [
63
+ {
64
+ PolicyName: "inline",
65
+ PolicyDocument: {
66
+ Statement: [
67
+ { Effect: "Allow", Action: "s3:GetObject", Resource: "*" },
68
+ { Effect: "Deny", Action: "s3:*", Resource: "*" },
69
+ ],
70
+ },
71
+ },
72
+ ],
73
+ },
74
+ },
75
+ },
76
+ });
77
+ expect(checkDenyAllowContradiction(ctx)).toHaveLength(1);
78
+ });
79
+
80
+ test("broad Allow with a narrow safety Deny (intentional guardrail) → no diagnostic", () => {
81
+ const ctx = makeCtx({
82
+ Resources: {
83
+ AppRole: {
84
+ Type: "AWS::IAM::Role",
85
+ Properties: {
86
+ AssumeRolePolicyDocument: { Statement: [] },
87
+ Policies: [
88
+ {
89
+ PolicyName: "inline",
90
+ PolicyDocument: {
91
+ Statement: [
92
+ { Effect: "Allow", Action: "ec2:*", Resource: "*" },
93
+ { Effect: "Deny", Action: "ec2:TerminateInstances", Resource: "*" },
94
+ ],
95
+ },
96
+ },
97
+ ],
98
+ },
99
+ },
100
+ },
101
+ });
102
+ expect(checkDenyAllowContradiction(ctx)).toHaveLength(0);
103
+ });
104
+
105
+ test("Deny on a different role than the Allow → no diagnostic", () => {
106
+ const ctx = makeCtx({
107
+ Resources: {
108
+ RoleA: {
109
+ Type: "AWS::IAM::Role",
110
+ Properties: {
111
+ AssumeRolePolicyDocument: { Statement: [] },
112
+ Policies: [{ PolicyName: "p", PolicyDocument: { Statement: [{ Effect: "Allow", Action: "ec2:ModifyInstanceAttribute", Resource: "*" }] } }],
113
+ },
114
+ },
115
+ RoleB: {
116
+ Type: "AWS::IAM::Role",
117
+ Properties: {
118
+ AssumeRolePolicyDocument: { Statement: [] },
119
+ Policies: [{ PolicyName: "p", PolicyDocument: { Statement: [{ Effect: "Deny", Action: "ec2:ModifyInstanceAttribute", Resource: "*" }] } }],
120
+ },
121
+ },
122
+ },
123
+ });
124
+ expect(checkDenyAllowContradiction(ctx)).toHaveLength(0);
125
+ });
126
+
127
+ test("Deny scoped to a disjoint literal resource → no diagnostic", () => {
128
+ const ctx = makeCtx({
129
+ Resources: {
130
+ AppRole: {
131
+ Type: "AWS::IAM::Role",
132
+ Properties: {
133
+ AssumeRolePolicyDocument: { Statement: [] },
134
+ Policies: [
135
+ {
136
+ PolicyName: "inline",
137
+ PolicyDocument: {
138
+ Statement: [
139
+ { Effect: "Allow", Action: "s3:GetObject", Resource: "arn:aws:s3:::bucket-a/*" },
140
+ { Effect: "Deny", Action: "s3:GetObject", Resource: "arn:aws:s3:::bucket-b/*" },
141
+ ],
142
+ },
143
+ },
144
+ ],
145
+ },
146
+ },
147
+ },
148
+ });
149
+ expect(checkDenyAllowContradiction(ctx)).toHaveLength(0);
150
+ });
151
+
152
+ test("conditional Deny is skipped (unprovable statically) → no diagnostic", () => {
153
+ const ctx = makeCtx({
154
+ Resources: {
155
+ AppRole: {
156
+ Type: "AWS::IAM::Role",
157
+ Properties: {
158
+ AssumeRolePolicyDocument: { Statement: [] },
159
+ Policies: [
160
+ {
161
+ PolicyName: "inline",
162
+ PolicyDocument: {
163
+ Statement: [
164
+ { Effect: "Allow", Action: "s3:GetObject", Resource: "*" },
165
+ {
166
+ Effect: "Deny",
167
+ Action: "s3:GetObject",
168
+ Resource: "*",
169
+ Condition: { StringNotEquals: { "aws:SourceVpc": "vpc-123" } },
170
+ },
171
+ ],
172
+ },
173
+ },
174
+ ],
175
+ },
176
+ },
177
+ },
178
+ });
179
+ expect(checkDenyAllowContradiction(ctx)).toHaveLength(0);
180
+ });
181
+
182
+ test("Deny from a managed policy attached via ManagedPolicyArns collides with an inline Allow → error", () => {
183
+ const ctx = makeCtx({
184
+ Resources: {
185
+ AppRole: {
186
+ Type: "AWS::IAM::Role",
187
+ Properties: {
188
+ AssumeRolePolicyDocument: { Statement: [] },
189
+ ManagedPolicyArns: [{ Ref: "GuardrailManagedPolicy" }],
190
+ Policies: [{ PolicyName: "inline", PolicyDocument: { Statement: [{ Effect: "Allow", Action: "ec2:ModifyInstanceAttribute", Resource: "*" }] } }],
191
+ },
192
+ },
193
+ GuardrailManagedPolicy: {
194
+ Type: "AWS::IAM::ManagedPolicy",
195
+ Properties: {
196
+ PolicyDocument: { Statement: [{ Effect: "Deny", Action: "ec2:ModifyInstanceAttribute", Resource: "*" }] },
197
+ },
198
+ },
199
+ },
200
+ });
201
+ const diags = checkDenyAllowContradiction(ctx);
202
+ expect(diags).toHaveLength(1);
203
+ expect(diags[0].message).toContain("GuardrailManagedPolicy");
204
+ });
205
+
206
+ test("role with only Allow statements → no diagnostic", () => {
207
+ const ctx = makeCtx({
208
+ Resources: {
209
+ AppRole: {
210
+ Type: "AWS::IAM::Role",
211
+ Properties: {
212
+ AssumeRolePolicyDocument: { Statement: [] },
213
+ Policies: [{ PolicyName: "p", PolicyDocument: { Statement: [{ Effect: "Allow", Action: "s3:GetObject", Resource: "*" }] } }],
214
+ },
215
+ },
216
+ },
217
+ });
218
+ expect(checkDenyAllowContradiction(ctx)).toHaveLength(0);
219
+ });
220
+ });
Binary file
@@ -46,6 +46,21 @@ export {
46
46
  } from "./aws-apply";
47
47
  export type { AwsApplyArgs, RollbackStackArgs, AwsHttp } from "./aws-apply";
48
48
 
49
+ // Effect-receipt activities (#1835) — core's receipt seam (#1834) bound to
50
+ // the SSM store, the way the k8s lexicon binds `ensureSecret` (#1830). The
51
+ // registry keys exported functions by name, so the three bound activities are
52
+ // re-exported individually: `receiptRead`/`receiptWrite` serve the `effect()`
53
+ // step's read-compare-run-write, `receiptStaleness` serves WatchOp's
54
+ // read-only phase. The store resolves its path identity and endpoint lazily
55
+ // at first use, so this module stays cheap to load.
56
+ import { receiptActivities } from "@intentius/chant/op/receipt-store";
57
+ import { awsReceiptStore } from "../../receipt-store";
58
+
59
+ const boundReceiptActivities = receiptActivities(awsReceiptStore());
60
+ export const receiptRead = boundReceiptActivities.receiptRead;
61
+ export const receiptWrite = boundReceiptActivities.receiptWrite;
62
+ export const receiptStaleness = boundReceiptActivities.receiptStaleness;
63
+
49
64
  export { awsAgentCoreFetchTrace } from "../../agentcore/trace-fetch";
50
65
  export type {
51
66
  AgentCoreTraceSource,
package/src/plugin.ts CHANGED
@@ -505,7 +505,7 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
505
505
  {
506
506
  file: "chant-aws-carve-terraform.md",
507
507
  name: "chant-aws-carve-terraform",
508
- description: "Demo carving a resource out of Terraform into native chant — advise, emit, bridge, apply — fully offline",
508
+ description: "Demo carving a resource out of Terraform into native chant — advise, emit, audit, bridge, apply — fully offline",
509
509
  triggers: [
510
510
  { type: "context", value: "terraform" },
511
511
  { type: "context", value: "carve" },
@@ -581,6 +581,17 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
581
581
  // single-stack convention is the stack named after the environment (#932).
582
582
  const stackName = options.stack ?? `${options.environment}`;
583
583
 
584
+ // Effect receipt rows (#1835): a receipt is never a stack member (the
585
+ // applier never writes it, #1832), so the stack read honestly reports it
586
+ // absent even while the parameter exists — which would arrive downstream
587
+ // as "the effect never ran". The serializer rendered each receipt's
588
+ // derived path into the template Metadata; read those parameters directly,
589
+ // and report a failed read as an `unobserved` hole rather than a wrong
590
+ // answer.
591
+ const { observeReceiptRows } = await import("./receipt-store");
592
+ const receiptObs = await observeReceiptRows(options.entityNames, options.buildOutput, client);
593
+ const receiptHoles = Object.keys(receiptObs.unobserved).length > 0 ? receiptObs.unobserved : undefined;
594
+
584
595
  // Describe stack resources. The endpoint override rides the client, so a
585
596
  // local emulator (Floci) is observed instead of real AWS (#926) — behold
586
597
  // serve --local relies on this for the overlay.
@@ -598,7 +609,12 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
598
609
  if (err instanceof AwsReadError && stackDoesNotExist(err.message)) {
599
610
  const { observeByIdentity } = await import("./identity-observe");
600
611
  const identity = await observeByIdentity(options.entityNames, options.entities, resources, client);
601
- return observation({ ...resources, ...identity.resources }, undefined, identity.queried, notes);
612
+ return observation(
613
+ { ...resources, ...identity.resources, ...receiptObs.resources },
614
+ receiptHoles,
615
+ identity.queried,
616
+ notes,
617
+ );
602
618
  }
603
619
  // Any other failure (credentials, throttling, a region that can't be
604
620
  // reached) establishes nothing about what is deployed. Reporting every
@@ -608,13 +624,16 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
608
624
  const reason = /credential|token|expired|AccessDenied|not authorized|Unauthorized/i.test(detail)
609
625
  ? "no-credentials"
610
626
  : "read-failed";
627
+ // A receipt row the leg above did read stays read: its answer came from
628
+ // GetParameter, not from the failed stack call, so it is not a hole.
629
+ const stackHoles = unobservedAll(
630
+ options.entityNames.filter((n) => !(n in receiptObs.resources)),
631
+ reason,
632
+ `DescribeStackResources failed for stack "${stackName}": ${detail}`,
633
+ );
611
634
  return observation(
612
- {},
613
- unobservedAll(
614
- options.entityNames,
615
- reason,
616
- `DescribeStackResources failed for stack "${stackName}": ${detail}`,
617
- ),
635
+ { ...receiptObs.resources },
636
+ { ...stackHoles, ...receiptObs.unobserved },
618
637
  undefined,
619
638
  notes,
620
639
  );
@@ -703,13 +722,25 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
703
722
  detail: `the stack was read, but this resource's own properties were not — ${own.failures.get(type) ?? "the describe call failed"}`,
704
723
  };
705
724
  }
706
- return observation({ ...described, ...identity.resources }, holes, identity.queried, notes, stackExports);
725
+ return observation(
726
+ { ...described, ...identity.resources, ...receiptObs.resources },
727
+ { ...holes, ...receiptObs.unobserved },
728
+ identity.queried,
729
+ notes,
730
+ stackExports,
731
+ );
707
732
  }
708
733
 
709
734
  // Every entity the stack answered for was answered for: an entity the
710
735
  // template doesn't carry is genuinely not in this stack, which is an
711
736
  // absence, not a hole — unless the identity fallback saw it live (#1647).
712
- return observation({ ...withProperties, ...identity.resources }, undefined, identity.queried, notes, stackExports);
737
+ return observation(
738
+ { ...withProperties, ...identity.resources, ...receiptObs.resources },
739
+ receiptHoles,
740
+ identity.queried,
741
+ notes,
742
+ stackExports,
743
+ );
713
744
  },
714
745
 
715
746
  /**
@@ -0,0 +1,204 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { awsReceiptStore, observeReceiptRows, ssmGetParameter, ssmPutParameter } from "./receipt-store";
3
+ import { EFFECT_RECEIPTS_METADATA_KEY } from "./effect-receipt-row";
4
+ import { receiptActivities, type EffectReceiptRef } from "@intentius/chant/op/receipt-store";
5
+ import type { AwsReadHttp } from "./api/read-client";
6
+ import { mkdtempSync } from "fs";
7
+ import { tmpdir } from "os";
8
+ import { join } from "path";
9
+
10
+ /** A directory with no chant.config.ts anywhere upward. */
11
+ const bareDir = mkdtempSync(join(tmpdir(), "chant-receipt-store-"));
12
+
13
+ interface Call {
14
+ url: string;
15
+ target: string;
16
+ body: Record<string, unknown>;
17
+ }
18
+
19
+ /** Fake SSM endpoint: records calls, answers from a name → value map. */
20
+ function fakeSsm(parameters: Record<string, string>, opts?: { failReads?: boolean }) {
21
+ const calls: Call[] = [];
22
+ const http: AwsReadHttp = async (url, init) => {
23
+ const body = JSON.parse(init.body) as Record<string, unknown>;
24
+ const target = init.headers["x-amz-target"] ?? "";
25
+ calls.push({ url, target: target.replace(/^AmazonSSM\./, ""), body });
26
+ if (target.endsWith("GetParameter")) {
27
+ if (opts?.failReads) return { status: 500, text: JSON.stringify({ __type: "InternalServerError" }) };
28
+ const value = parameters[body.Name as string];
29
+ if (value === undefined) {
30
+ return { status: 400, text: JSON.stringify({ __type: "ParameterNotFound" }) };
31
+ }
32
+ return { status: 200, text: JSON.stringify({ Parameter: { Name: body.Name, Value: value } }) };
33
+ }
34
+ if (target.endsWith("PutParameter")) {
35
+ if (parameters[body.Name as string] !== undefined && body.Overwrite !== true) {
36
+ return { status: 400, text: JSON.stringify({ __type: "ParameterAlreadyExists" }) };
37
+ }
38
+ parameters[body.Name as string] = body.Value as string;
39
+ return { status: 200, text: JSON.stringify({ Version: 1 }) };
40
+ }
41
+ return { status: 400, text: JSON.stringify({ __type: "InvalidAction" }) };
42
+ };
43
+ return { calls, http, parameters };
44
+ }
45
+
46
+ const ref: EffectReceiptRef = { name: "seeded", effect: "db-seed", flavor: "hash", inputs: { v: 1 } };
47
+
48
+ function store(fake: ReturnType<typeof fakeSsm>, env: Record<string, string | undefined> = {}) {
49
+ return awsReceiptStore({
50
+ stack: "demo",
51
+ environment: "dev",
52
+ cwd: bareDir,
53
+ http: fake.http,
54
+ env,
55
+ });
56
+ }
57
+
58
+ describe("awsReceiptStore", () => {
59
+ it("read returns the stored value at the derived path", async () => {
60
+ const fake = fakeSsm({ "/chant-receipts/demo/dev/db-seed": "sha256:abc" });
61
+ await expect(store(fake).read(ref)).resolves.toBe("sha256:abc");
62
+ expect(fake.calls[0].target).toBe("GetParameter");
63
+ expect(fake.calls[0].body.Name).toBe("/chant-receipts/demo/dev/db-seed");
64
+ });
65
+
66
+ it("read returns undefined for an absent receipt — a real answer, not an error", async () => {
67
+ const fake = fakeSsm({});
68
+ await expect(store(fake).read(ref)).resolves.toBeUndefined();
69
+ });
70
+
71
+ it("read throws on a failed read rather than answering wrongly", async () => {
72
+ const fake = fakeSsm({}, { failReads: true });
73
+ await expect(store(fake).read(ref)).rejects.toThrow(/GetParameter/);
74
+ });
75
+
76
+ it("write creates plain String with the ownership tags", async () => {
77
+ const fake = fakeSsm({});
78
+ await store(fake).write(ref, "sha256:abc");
79
+ expect(fake.calls).toHaveLength(1);
80
+ const { body } = fake.calls[0];
81
+ expect(body.Type).toBe("String");
82
+ expect(body.Overwrite).toBeUndefined();
83
+ expect(body.Tags).toContainEqual({ Key: "chant:managed-by", Value: "chant" });
84
+ expect(body.Tags).toContainEqual({ Key: "chant:stack", Value: "demo" });
85
+ expect(body.Tags).toContainEqual({ Key: "chant:env", Value: "dev" });
86
+ expect(fake.parameters["/chant-receipts/demo/dev/db-seed"]).toBe("sha256:abc");
87
+ });
88
+
89
+ it("write overwrites an existing receipt — String + Overwrite, no tags on the retry", async () => {
90
+ const fake = fakeSsm({ "/chant-receipts/demo/dev/db-seed": "sha256:old" });
91
+ await store(fake).write(ref, "sha256:new");
92
+ expect(fake.calls.map((c) => c.target)).toEqual(["PutParameter", "PutParameter"]);
93
+ const retry = fake.calls[1].body;
94
+ expect(retry.Type).toBe("String");
95
+ expect(retry.Overwrite).toBe(true);
96
+ expect(retry.Tags).toBeUndefined();
97
+ expect(fake.parameters["/chant-receipts/demo/dev/db-seed"]).toBe("sha256:new");
98
+ });
99
+
100
+ it("honors AWS_ENDPOINT_URL_SSM, then AWS_ENDPOINT_URL (#1694)", async () => {
101
+ const perService = fakeSsm({});
102
+ await store(perService, { AWS_ENDPOINT_URL_SSM: "http://localhost:4566", AWS_ENDPOINT_URL: "http://elsewhere:1" }).read(ref);
103
+ expect(perService.calls[0].url).toBe("http://localhost:4566/");
104
+
105
+ const ambient = fakeSsm({});
106
+ await store(ambient, { AWS_ENDPOINT_URL: "http://localhost:4566" }).read(ref);
107
+ expect(ambient.calls[0].url).toBe("http://localhost:4566/");
108
+ });
109
+
110
+ it("errors without a stack identity", async () => {
111
+ const fake = fakeSsm({});
112
+ const s = awsReceiptStore({ environment: "dev", cwd: bareDir, http: fake.http, env: {} });
113
+ await expect(s.read(ref)).rejects.toThrow(/ownership: \{ stack \}/);
114
+ });
115
+
116
+ it("errors without an env — the segment is explicit (decision 4)", async () => {
117
+ const fake = fakeSsm({});
118
+ const s = awsReceiptStore({ stack: "demo", cwd: bareDir, http: fake.http, env: {} });
119
+ await expect(s.read(ref)).rejects.toThrow(/CHANT_ENV/);
120
+ });
121
+
122
+ it("reads CHANT_ENV for the env segment", async () => {
123
+ const fake = fakeSsm({ "/chant-receipts/demo/staging/db-seed": "x" });
124
+ const s = awsReceiptStore({ stack: "demo", cwd: bareDir, http: fake.http, env: { CHANT_ENV: "staging" } });
125
+ await expect(s.read(ref)).resolves.toBe("x");
126
+ });
127
+ });
128
+
129
+ describe("receiptActivities over the aws store", () => {
130
+ it("resolves receiptRead/receiptWrite/receiptStaleness against the SSM store", async () => {
131
+ const fake = fakeSsm({});
132
+ const activities = receiptActivities(store(fake));
133
+
134
+ const read = await activities.receiptRead({ receipt: ref, expectation: "sha256:abc" });
135
+ expect(read).toEqual({ current: null, expectation: "sha256:abc", applied: false });
136
+
137
+ await activities.receiptWrite({ receipt: ref, expectation: "sha256:abc" });
138
+ const again = await activities.receiptRead({ receipt: ref, expectation: "sha256:abc" });
139
+ expect(again.applied).toBe(true);
140
+
141
+ const staleness = await activities.receiptStaleness({
142
+ receipts: [{ receipt: ref, expectation: "sha256:other" }],
143
+ });
144
+ expect(staleness.stale).toBe(true);
145
+ expect(staleness.findings[0]).toMatchObject({ receipt: "seeded", kind: "differs" });
146
+ });
147
+
148
+ it("is wired into the op activities barrel by name", async () => {
149
+ const barrel = await import("./op/activities/index");
150
+ expect(typeof barrel.receiptRead).toBe("function");
151
+ expect(typeof barrel.receiptWrite).toBe("function");
152
+ expect(typeof barrel.receiptStaleness).toBe("function");
153
+ });
154
+ });
155
+
156
+ describe("observeReceiptRows (plan's live read)", () => {
157
+ const buildOutput = JSON.stringify({
158
+ AWSTemplateFormatVersion: "2010-09-09",
159
+ Metadata: {
160
+ [EFFECT_RECEIPTS_METADATA_KEY]: {
161
+ seeded: {
162
+ Type: "AWS::SSM::Parameter",
163
+ Properties: { Name: "/chant-receipts/demo/dev/db-seed", Type: "String", Value: "sha256:abc", Tags: [] },
164
+ },
165
+ },
166
+ },
167
+ Resources: {},
168
+ });
169
+
170
+ it("maps a present receipt's stored value onto attributes.value", async () => {
171
+ const fake = fakeSsm({ "/chant-receipts/demo/dev/db-seed": "sha256:abc" });
172
+ const obs = await observeReceiptRows(["seeded", "unrelated"], buildOutput, { http: fake.http, env: {} });
173
+ expect(obs.resources.seeded).toMatchObject({
174
+ type: "AWS::SSM::Parameter",
175
+ physicalId: "/chant-receipts/demo/dev/db-seed",
176
+ status: "EXTERNAL",
177
+ attributes: { value: "sha256:abc" },
178
+ });
179
+ expect(obs.unobserved).toEqual({});
180
+ // Only the rendered receipt was queried — never a guess for other entities.
181
+ expect(fake.calls).toHaveLength(1);
182
+ });
183
+
184
+ it("reports a confirmed absence as absence — in neither map", async () => {
185
+ const fake = fakeSsm({});
186
+ const obs = await observeReceiptRows(["seeded"], buildOutput, { http: fake.http, env: {} });
187
+ expect(obs.resources).toEqual({});
188
+ expect(obs.unobserved).toEqual({});
189
+ });
190
+
191
+ it("reports a failed read as unobserved, never a wrong answer", async () => {
192
+ const fake = fakeSsm({}, { failReads: true });
193
+ const obs = await observeReceiptRows(["seeded"], buildOutput, { http: fake.http, env: {} });
194
+ expect(obs.resources).toEqual({});
195
+ expect(obs.unobserved.seeded).toMatchObject({ type: "AWS::SSM::Parameter", reason: "read-failed" });
196
+ });
197
+
198
+ it("answers nothing for a template without receipt rows", async () => {
199
+ const fake = fakeSsm({});
200
+ const obs = await observeReceiptRows(["seeded"], JSON.stringify({ Resources: {} }), { http: fake.http, env: {} });
201
+ expect(obs).toEqual({ resources: {}, unobserved: {} });
202
+ expect(fake.calls).toHaveLength(0);
203
+ });
204
+ });