@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
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
  );
@@ -656,6 +675,9 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
656
675
  // this path cannot read the ownership marker. Say `unknown` explicitly
657
676
  // rather than leaving the field off and letting each consumer guess —
658
677
  // the change set never escalates `unknown` to a delete.
678
+ // For the same reason `marker` (#1222) stays absent here: no tags, no
679
+ // stack/env identity to read, and absent means absent — never a guess.
680
+ // aws teardown is stack-level and reads the stack's own tags instead.
659
681
  ownership: "unknown",
660
682
  };
661
683
  }
@@ -700,13 +722,25 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
700
722
  detail: `the stack was read, but this resource's own properties were not — ${own.failures.get(type) ?? "the describe call failed"}`,
701
723
  };
702
724
  }
703
- 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
+ );
704
732
  }
705
733
 
706
734
  // Every entity the stack answered for was answered for: an entity the
707
735
  // template doesn't carry is genuinely not in this stack, which is an
708
736
  // absence, not a hole — unless the identity fallback saw it live (#1647).
709
- 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
+ );
710
744
  },
711
745
 
712
746
  /**
@@ -803,6 +837,21 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
803
837
  return { stack: options.stack, present: true, status, healthy };
804
838
  },
805
839
 
840
+ // Env teardown at STACK granularity (#1222): `describeResources` carries no
841
+ // tags, so per-resource marker selection is impossible here — the env's
842
+ // stacks are enumerated instead (`stacks[]`, else the env-named default) and
843
+ // ownership is verified on each stack's own DescribeStacks tags. Execution
844
+ // is DeleteStack via the applier's `awsDelete`. See ./teardown.ts.
845
+ async teardownOwned(options) {
846
+ const { teardownOwned } = await import("./teardown");
847
+ return teardownOwned(options);
848
+ },
849
+
850
+ async executeTeardown(options) {
851
+ const { executeTeardown } = await import("./teardown");
852
+ return executeTeardown(options);
853
+ },
854
+
806
855
  async exportResources(options: {
807
856
  environment: string;
808
857
  stack?: string;
@@ -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
+ });
@@ -0,0 +1,315 @@
1
+ /**
2
+ * The aws `ReceiptStore` (#1835, epic #1703) — core's injectable receipt seam
3
+ * (#1834, `@intentius/chant/op/receipt-store`) implemented over SSM Parameter
4
+ * Store, plain `String`, at the path identity ./effect-receipt-row.ts derives
5
+ * from the ownership marker fields.
6
+ *
7
+ * The transport is the lexicon's own read/apply transport (./api/read-client,
8
+ * #1206), pointed at the SSM JSON API: `fetch`, SigV4 when credentials
9
+ * resolve, and the one endpoint-override rule (#1694) — the `endpoint`
10
+ * option, else `AWS_ENDPOINT_URL_SSM`, else `AWS_ENDPOINT_URL` — so a local
11
+ * emulator lane reads and writes receipts without any store-specific wiring.
12
+ *
13
+ * Write discipline (epic decision 3): `write` exists for the `effect()` step
14
+ * alone — the step's read-compare-run-write is the only path that reaches it,
15
+ * on success, last. `PutParameter` is `Type: "String"` always; the first
16
+ * write creates the parameter with the ownership tags, and a later write
17
+ * overwrites the value (SSM refuses `Overwrite` and `Tags` in one call, so
18
+ * tags ride creation only — they never change after).
19
+ *
20
+ * Identity: the parameter name needs `<stack>` and `<env>`, which the
21
+ * activity args deliberately do not carry (the `EffectReceiptRef` is
22
+ * identity-of-the-effect, not identity-of-the-deployment). The store resolves
23
+ * them once, lazily, at first use: an explicit option, else `CHANT_ENV` (what
24
+ * `chant run --env` sets) and the project's `ownership` block — the same
25
+ * fields that stamp markers (epic decision 4). Nothing resolving is an error,
26
+ * never a guessed segment.
27
+ */
28
+
29
+ import { loadChantConfigUpward, resolveOwnershipStack } from "@intentius/chant/config";
30
+ import { ownershipEntries } from "@intentius/chant/ownership";
31
+ import type { EffectReceiptRef, ReceiptStore } from "@intentius/chant/op/receipt-store";
32
+ import type { ResourceMetadata } from "@intentius/chant/lexicon";
33
+ import type { UnobservedReason } from "@intentius/chant/observation";
34
+ import {
35
+ AwsReadError,
36
+ requestHeaders,
37
+ serviceUrl,
38
+ withEndpointOverride,
39
+ type AwsCredentialSource,
40
+ type AwsReadClientOptions,
41
+ type AwsReadHttp,
42
+ } from "./api/read-client";
43
+ import { AWS_TAG_OWNERSHIP_KEYS } from "./ownership";
44
+ import { AWS_EFFECT_RECEIPT_ENTITY_TYPE, EFFECT_RECEIPTS_METADATA_KEY, receiptParameterName } from "./effect-receipt-row";
45
+
46
+ const SSM_SERVICE = "ssm";
47
+ const SSM_TARGET_PREFIX = "AmazonSSM";
48
+
49
+ /** Options for {@link awsReceiptStore}. All optional: the default store reads
50
+ * its identity from the project and its endpoint from the environment. */
51
+ export interface AwsReceiptStoreOptions {
52
+ /** The path's `<stack>` segment. Omitted, the project's `ownership.stack`
53
+ * (chant.config.ts, found upward from `cwd`) answers. */
54
+ stack?: string;
55
+ /** The path's `<env>` segment — explicit by decision 4. Omitted, `CHANT_ENV`
56
+ * (set by `chant run --env`) answers, then a literal `ownership.env`. */
57
+ environment?: string;
58
+ /** Where to look for chant.config.ts. Defaults to the working directory. */
59
+ cwd?: string;
60
+ /** Endpoint override; omitted, `AWS_ENDPOINT_URL[_SSM]` answers (#1694). */
61
+ endpoint?: string;
62
+ /** Region for the real-AWS host. */
63
+ region?: string;
64
+ /** Injectable HTTP, mirroring the read client's. Tests avoid the network. */
65
+ http?: AwsReadHttp;
66
+ /** Environment record the endpoint/credential/identity fallbacks read.
67
+ * Defaults to `process.env`; injectable for tests. */
68
+ env?: Record<string, string | undefined>;
69
+ /** What to sign with — same seam as the read client. */
70
+ credentials?: AwsCredentialSource;
71
+ /** Sign even against an endpoint override — for an override that is real AWS. */
72
+ signEndpointOverride?: boolean;
73
+ }
74
+
75
+ /** One SSM JSON call. Exported for the observation leg (plugin.ts), which
76
+ * reads the same parameters the store writes. */
77
+ export async function ssmCall(
78
+ action: string,
79
+ payload: Record<string, unknown>,
80
+ options: AwsReadClientOptions,
81
+ ): Promise<{ status: number; json: Record<string, unknown> }> {
82
+ const settled = withEndpointOverride(SSM_SERVICE, options);
83
+ const url = serviceUrl(SSM_SERVICE, settled.endpoint, settled.region);
84
+ const body = JSON.stringify(payload);
85
+ const base = {
86
+ "content-type": "application/x-amz-json-1.1",
87
+ "x-amz-target": `${SSM_TARGET_PREFIX}.${action}`,
88
+ };
89
+ const headers = requestHeaders(SSM_SERVICE, url, body, base, settled);
90
+ const http: AwsReadHttp =
91
+ settled.http ??
92
+ (async (u, init, signal) => {
93
+ const res = await fetch(u, { method: "POST", headers: init.headers, body: init.body, signal });
94
+ return { status: res.status, text: await res.text() };
95
+ });
96
+ const res = await http(url, { headers, body }, settled.signal);
97
+ let json: Record<string, unknown> = {};
98
+ try {
99
+ json = res.text ? (JSON.parse(res.text) as Record<string, unknown>) : {};
100
+ } catch {
101
+ if (res.status < 400) {
102
+ throw new AwsReadError(`SSM ${action}: unparseable response (status ${res.status})`, res.status);
103
+ }
104
+ }
105
+ return { status: res.status, json };
106
+ }
107
+
108
+ /** The API's own error code from a JSON-protocol error body — `__type`, with
109
+ * any `namespace#` prefix stripped. */
110
+ export function ssmErrorCode(json: Record<string, unknown>): string | undefined {
111
+ const type = json.__type;
112
+ if (typeof type !== "string") return undefined;
113
+ return type.includes("#") ? type.slice(type.indexOf("#") + 1) : type;
114
+ }
115
+
116
+ function ssmError(action: string, status: number, json: Record<string, unknown>): AwsReadError {
117
+ const code = ssmErrorCode(json);
118
+ const message = typeof json.message === "string" ? json.message : typeof json.Message === "string" ? json.Message : "";
119
+ return new AwsReadError(`SSM ${action} failed (status ${status})${code ? ` ${code}` : ""}${message ? `: ${message}` : ""}`, status, code);
120
+ }
121
+
122
+ /**
123
+ * `GetParameter` by name. Absent (`ParameterNotFound`) is `undefined` — a real
124
+ * answer, distinct from a failed read, which throws.
125
+ */
126
+ export async function ssmGetParameter(
127
+ name: string,
128
+ options: AwsReadClientOptions = {},
129
+ ): Promise<string | undefined> {
130
+ const { status, json } = await ssmCall("GetParameter", { Name: name }, options);
131
+ if (status >= 400) {
132
+ if (ssmErrorCode(json) === "ParameterNotFound") return undefined;
133
+ throw ssmError("GetParameter", status, json);
134
+ }
135
+ const value = (json.Parameter as { Value?: unknown } | undefined)?.Value;
136
+ return typeof value === "string" ? value : undefined;
137
+ }
138
+
139
+ /**
140
+ * `PutParameter`, plain `String`. Creation carries `tags`; an existing
141
+ * parameter is overwritten (`Overwrite: true`) without them — SSM refuses
142
+ * `Overwrite` and `Tags` in the same call, and ownership tags never change.
143
+ */
144
+ export async function ssmPutParameter(
145
+ name: string,
146
+ value: string,
147
+ tags: Record<string, string>,
148
+ options: AwsReadClientOptions = {},
149
+ ): Promise<void> {
150
+ const tagList = Object.entries(tags).map(([Key, Value]) => ({ Key, Value }));
151
+ const create = await ssmCall(
152
+ "PutParameter",
153
+ { Name: name, Value: value, Type: "String", ...(tagList.length > 0 ? { Tags: tagList } : {}) },
154
+ options,
155
+ );
156
+ if (create.status < 400) return;
157
+ if (ssmErrorCode(create.json) !== "ParameterAlreadyExists") {
158
+ throw ssmError("PutParameter", create.status, create.json);
159
+ }
160
+ const overwrite = await ssmCall(
161
+ "PutParameter",
162
+ { Name: name, Value: value, Type: "String", Overwrite: true },
163
+ options,
164
+ );
165
+ if (overwrite.status >= 400) throw ssmError("PutParameter", overwrite.status, overwrite.json);
166
+ }
167
+
168
+ /** The resolved path identity plus the tags a creation stamps. */
169
+ interface ReceiptIdentity {
170
+ stack: string;
171
+ env: string;
172
+ }
173
+
174
+ async function resolveIdentity(options: AwsReceiptStoreOptions): Promise<ReceiptIdentity> {
175
+ const processEnv = options.env ?? process.env;
176
+ let stack = options.stack;
177
+ let env = options.environment ?? processEnv.CHANT_ENV;
178
+ if (!stack || !env) {
179
+ let config;
180
+ try {
181
+ config = (await loadChantConfigUpward(options.cwd ?? process.cwd())).config;
182
+ } catch {
183
+ config = undefined;
184
+ }
185
+ if (config) {
186
+ stack = stack ?? resolveOwnershipStack(config);
187
+ // Only a literal env can answer here: a `{ param }` reference resolves
188
+ // per build, and an op run has no build parameters — `--env` does.
189
+ const configEnv = config.ownership?.env;
190
+ env = env ?? (typeof configEnv === "string" ? configEnv : undefined);
191
+ }
192
+ }
193
+ if (!stack) {
194
+ throw new Error(
195
+ "aws receipt store: no stack identity — the receipt path is " +
196
+ "/chant-receipts/<stack>/<env>/<effect>, derived from the same ownership fields that stamp " +
197
+ "markers (chant #1703, decision 4). Set ownership: { stack } in chant.config.ts.",
198
+ );
199
+ }
200
+ if (!env) {
201
+ throw new Error(
202
+ "aws receipt store: no environment resolved — the receipt path's <env> segment is explicit " +
203
+ "(chant #1703, decision 4). Run with --env <name>, set CHANT_ENV, or set a literal " +
204
+ "ownership.env in chant.config.ts.",
205
+ );
206
+ }
207
+ return { stack, env };
208
+ }
209
+
210
+ /** What the observation leg learned about the declared receipt rows. */
211
+ export interface ReceiptRowObservation {
212
+ resources: Record<string, ResourceMetadata>;
213
+ unobserved: Record<string, { type: string; reason: UnobservedReason; detail: string }>;
214
+ }
215
+
216
+ /**
217
+ * The plan-side live read of the receipt rows (#1835's observation leg).
218
+ *
219
+ * A receipt is not a stack member — the applier never writes it (#1832) — so
220
+ * `describe-stack-resources` honestly reports it absent even while the
221
+ * parameter exists. The serializer renders each receipt's derived path into
222
+ * the template's `Metadata` (./serializer.ts), so this leg reads the paths
223
+ * back from the build output — one derivation, decision 4 — and asks SSM
224
+ * `GetParameter` for each. Present maps the stored value onto
225
+ * `attributes.value` (core's `RECEIPT_VALUE_ATTRIBUTE`); `ParameterNotFound`
226
+ * is a real absence and stays one; a failed read is an `unobserved` hole,
227
+ * never a wrong answer — a receipt nobody could read must not arrive
228
+ * downstream as "the effect never ran".
229
+ */
230
+ export async function observeReceiptRows(
231
+ entityNames: string[],
232
+ buildOutput: string,
233
+ options: AwsReadClientOptions = {},
234
+ ): Promise<ReceiptRowObservation> {
235
+ const out: ReceiptRowObservation = { resources: {}, unobserved: {} };
236
+ let rows: Record<string, { Properties?: { Name?: unknown } }> | undefined;
237
+ try {
238
+ const template = JSON.parse(buildOutput) as { Metadata?: Record<string, unknown> };
239
+ const block = template.Metadata?.[EFFECT_RECEIPTS_METADATA_KEY];
240
+ if (typeof block === "object" && block !== null) {
241
+ rows = block as Record<string, { Properties?: { Name?: unknown } }>;
242
+ }
243
+ } catch {
244
+ rows = undefined; // Not a JSON template — no receipt rows to read.
245
+ }
246
+ if (!rows) return out;
247
+
248
+ for (const name of entityNames) {
249
+ const rendered = rows[name]?.Properties?.Name;
250
+ if (typeof rendered !== "string") continue;
251
+ try {
252
+ const value = await ssmGetParameter(rendered, options);
253
+ if (value === undefined) continue; // Confirmed absent — the effect has not stamped it yet.
254
+ out.resources[name] = {
255
+ type: AWS_EFFECT_RECEIPT_ENTITY_TYPE,
256
+ physicalId: rendered,
257
+ // Live outside the stack by design — the same word the identity
258
+ // fallback (#1647) uses for a resource CloudFormation does not hold.
259
+ status: "EXTERNAL",
260
+ // GetParameter returns no tags, so the marker channel is unreadable
261
+ // here; `unknown` is the total verdict, never a guess (#1089).
262
+ ownership: "unknown",
263
+ attributes: { value },
264
+ };
265
+ } catch (err) {
266
+ const detail =
267
+ err instanceof AwsReadError && err.code
268
+ ? `${err.code}: ${err.message}`
269
+ : String(err instanceof Error ? err.message : err);
270
+ const reason: UnobservedReason = /credential|token|expired|AccessDenied|not authorized|Unauthorized/i.test(detail)
271
+ ? "no-credentials"
272
+ : "read-failed";
273
+ out.unobserved[name] = {
274
+ type: AWS_EFFECT_RECEIPT_ENTITY_TYPE,
275
+ reason,
276
+ detail: `GetParameter failed for receipt "${rendered}": ${detail}`,
277
+ };
278
+ }
279
+ }
280
+ return out;
281
+ }
282
+
283
+ /**
284
+ * The `ReceiptStore` over SSM. Bind it once in the op activities barrel —
285
+ * `receiptActivities(awsReceiptStore())` — and the registry resolves
286
+ * `receiptRead`/`receiptWrite`/`receiptStaleness` by name, exactly like
287
+ * `ensureSecret` (#1830). Identity and endpoint resolve lazily at first use,
288
+ * so module load never reads the project or the environment.
289
+ */
290
+ export function awsReceiptStore(options: AwsReceiptStoreOptions = {}): ReceiptStore {
291
+ let identity: Promise<ReceiptIdentity> | undefined;
292
+ const identityOf = () => (identity ??= resolveIdentity(options));
293
+
294
+ const client = (): AwsReadClientOptions => ({
295
+ ...(options.endpoint !== undefined ? { endpoint: options.endpoint } : {}),
296
+ ...(options.region !== undefined ? { region: options.region } : {}),
297
+ ...(options.http !== undefined ? { http: options.http } : {}),
298
+ ...(options.env !== undefined ? { env: options.env } : {}),
299
+ ...(options.credentials !== undefined ? { credentials: options.credentials } : {}),
300
+ ...(options.signEndpointOverride !== undefined ? { signEndpointOverride: options.signEndpointOverride } : {}),
301
+ });
302
+
303
+ return {
304
+ async read(receipt: EffectReceiptRef): Promise<string | undefined> {
305
+ const { stack, env } = await identityOf();
306
+ return ssmGetParameter(receiptParameterName(stack, env, receipt.effect), client());
307
+ },
308
+
309
+ async write(receipt: EffectReceiptRef, expectation: string): Promise<void> {
310
+ const { stack, env } = await identityOf();
311
+ const tags = ownershipEntries(AWS_TAG_OWNERSHIP_KEYS, { stack, env });
312
+ await ssmPutParameter(receiptParameterName(stack, env, receipt.effect), expectation, tags, client());
313
+ },
314
+ };
315
+ }
@@ -34,4 +34,22 @@ describe("awsSerializer ownership stamping (#119)", () => {
34
34
  const tags = (template.Resources.MyBucket.Properties?.Tags ?? []) as Array<{ Key: string }>;
35
35
  expect(tags.some((t) => t.Key.startsWith("chant:"))).toBe(false);
36
36
  });
37
+
38
+ test("carries the marker at the template level too — Metadata[chant:ownership], the stack-tag source (#1222)", () => {
39
+ const entities = new Map<string, Declarable>([["MyBucket", new MockBucket({ BucketName: "b" })]]);
40
+ const out = awsSerializer.serialize(entities, [], { ownership: { stack: "billing", env: "prod" } });
41
+ const template = JSON.parse(out as string);
42
+ expect(template.Metadata["chant:ownership"]).toEqual({
43
+ "chant:managed-by": "chant",
44
+ "chant:stack": "billing",
45
+ "chant:env": "prod",
46
+ });
47
+ });
48
+
49
+ test("no ownership context → no template Metadata block", () => {
50
+ const entities = new Map<string, Declarable>([["MyBucket", new MockBucket({ BucketName: "b" })]]);
51
+ const out = awsSerializer.serialize(entities, []);
52
+ const template = JSON.parse(out as string);
53
+ expect(template.Metadata).toBeUndefined();
54
+ });
37
55
  });