@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,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
+ }
package/src/serializer.ts CHANGED
@@ -2,7 +2,19 @@ import type { Declarable, CoreParameter } from "@intentius/chant/declarable";
2
2
  import { isPropertyDeclarable, isResourceDeclarable } from "@intentius/chant/declarable";
3
3
  import type { Serializer, SerializerResult, SerializeContext } from "@intentius/chant/serializer";
4
4
  import { ownershipEntries, type OwnershipMarker } from "@intentius/chant/ownership";
5
+ import {
6
+ isEffectReceipt,
7
+ receiptExpectation,
8
+ referenceInputPaths,
9
+ type EffectReceiptDeclaration,
10
+ } from "@intentius/chant/effect-receipt";
5
11
  import { AWS_TAG_OWNERSHIP_KEYS, OWNERSHIP_METADATA_KEY } from "./ownership";
12
+ import {
13
+ AWS_EFFECT_RECEIPT_ENTITY_TYPE,
14
+ EFFECT_RECEIPTS_METADATA_KEY,
15
+ RECEIPT_UNRESOLVED_VALUE_NOTE,
16
+ receiptParameterName,
17
+ } from "./effect-receipt-row";
6
18
  import type { LexiconOutput } from "@intentius/chant/lexicon-output";
7
19
  import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
8
20
  import { isChildProject, type ChildProjectInstance } from "@intentius/chant/child-project";
@@ -154,6 +166,80 @@ function toProperties(
154
166
  }
155
167
 
156
168
 
169
+ /** One rendered receipt row — a CFN-resource-shaped object that lives in the
170
+ * template's `Metadata`, never in `Resources` (#1832: the applier writes from
171
+ * `Resources`, and the `effect()` step is a receipt's sole writer). */
172
+ interface ReceiptRow {
173
+ Type: typeof AWS_EFFECT_RECEIPT_ENTITY_TYPE;
174
+ Properties: {
175
+ Name: string;
176
+ Type: "String";
177
+ Value: string;
178
+ Tags: Array<{ Key: string; Value: string }>;
179
+ };
180
+ }
181
+
182
+ /** The rendered `Value`: the synthesis-time expectation when the receipt is
183
+ * fully static, a placeholder note when reference inputs resolve later —
184
+ * never a digest hashed over placeholders (epic #1703, decision 5). */
185
+ function receiptRowValue(receipt: EffectReceiptDeclaration): string {
186
+ if (receipt.flavor === "hash" && referenceInputPaths(receipt).length > 0) {
187
+ return RECEIPT_UNRESOLVED_VALUE_NOTE;
188
+ }
189
+ return receiptExpectation(receipt);
190
+ }
191
+
192
+ /**
193
+ * Render the effect receipts (#1835) the build withheld from the apply-bound
194
+ * entity set (`SerializeContext.receipts`, #1832) as `AWS::SSM::Parameter`
195
+ * rows: plain `String`, named `/chant-receipts/<stack>/<env>/<effect>` from
196
+ * the resolved ownership marker (epic decision 4 — the same fields that stamp
197
+ * tags), carrying the ownership tags. Visibility only: the rows go under the
198
+ * template's `Metadata`, and the receipt store (./receipt-store.ts) is what
199
+ * actually writes the parameter — through the `effect()` step, on success,
200
+ * last.
201
+ *
202
+ * The env segment is explicit: a receipt with no resolved `ownership.env` is
203
+ * an error here, never a guessed path.
204
+ */
205
+ function renderReceiptRows(
206
+ receipts: ReadonlyMap<string, Declarable>,
207
+ ownership: OwnershipMarker | undefined,
208
+ ): Record<string, ReceiptRow> {
209
+ const rows: Record<string, ReceiptRow> = {};
210
+ const names = [...receipts.keys()].join(", ");
211
+ if (!ownership) {
212
+ throw new Error(
213
+ `aws receipts (${names}): no ownership marker resolved — the receipt path is ` +
214
+ `/chant-receipts/<stack>/<env>/<effect>, derived from the same ownership fields that ` +
215
+ `stamp markers (chant #1703, decision 4). Set ownership: { stack, env } in chant.config.ts.`,
216
+ );
217
+ }
218
+ if (!ownership.env) {
219
+ throw new Error(
220
+ `aws receipts (${names}): ownership resolved no env — the receipt path's <env> segment is ` +
221
+ `explicit (chant #1703, decision 4). Set ownership.env in chant.config.ts (a literal, or ` +
222
+ `{ param: "env" } with --param env=<name>).`,
223
+ );
224
+ }
225
+ const tags = Object.entries(ownershipEntries(AWS_TAG_OWNERSHIP_KEYS, ownership)).map(
226
+ ([Key, Value]) => ({ Key, Value }),
227
+ );
228
+ for (const [name, entity] of receipts) {
229
+ if (!isEffectReceipt(entity)) continue;
230
+ rows[name] = {
231
+ Type: AWS_EFFECT_RECEIPT_ENTITY_TYPE,
232
+ Properties: {
233
+ Name: receiptParameterName(ownership.stack, ownership.env, entity.effect),
234
+ Type: "String",
235
+ Value: receiptRowValue(entity),
236
+ Tags: tags,
237
+ },
238
+ };
239
+ }
240
+ return rows;
241
+ }
242
+
157
243
  /**
158
244
  * Serialize a set of entities into a CFTemplate object (without JSON.stringify).
159
245
  */
@@ -163,6 +249,7 @@ function serializeToTemplate(
163
249
  extraParameters?: Record<string, CFParameter>,
164
250
  extraOutputs?: Record<string, CFOutput>,
165
251
  ownership?: OwnershipMarker,
252
+ receiptRows?: Record<string, ReceiptRow>,
166
253
  ): CFTemplate {
167
254
  const template: CFTemplate = {
168
255
  AWSTemplateFormatVersion: "2010-09-09",
@@ -196,6 +283,14 @@ function serializeToTemplate(
196
283
  [OWNERSHIP_METADATA_KEY]: ownershipEntries(AWS_TAG_OWNERSHIP_KEYS, ownership),
197
284
  };
198
285
  }
286
+ // Effect receipt rows (#1835) — visibility only, deliberately outside
287
+ // `Resources`: the applier's desired and prune sets both read `Resources`,
288
+ // and the `effect()` step is a receipt's sole writer (#1832, epic #1703
289
+ // decision 3). The observation leg (plugin.ts) reads the paths back from
290
+ // this block, so the identity is derived exactly once, here.
291
+ if (receiptRows && Object.keys(receiptRows).length > 0) {
292
+ template.Metadata = { ...(template.Metadata ?? {}), [EFFECT_RECEIPTS_METADATA_KEY]: receiptRows };
293
+ }
199
294
  for (const [, entity] of entities) {
200
295
  if (isDefaultTags(entity)) {
201
296
  defaultTagEntries.push(...entity.tags);
@@ -412,6 +507,14 @@ export const awsSerializer: Serializer = {
412
507
  serialize(entities: Map<string, Declarable>, outputs?: LexiconOutput[], context?: SerializeContext): string | SerializerResult {
413
508
  const ownership = context?.ownership;
414
509
 
510
+ // Effect receipts (#1835): withheld from `entities` by the build (#1832),
511
+ // rendered here as Metadata rows — never as `Resources` an applier would
512
+ // write. Env-less ownership is an error, not a guessed path segment.
513
+ const receiptRows =
514
+ context?.receipts && context.receipts.size > 0
515
+ ? renderReceiptRows(context.receipts, ownership)
516
+ : undefined;
517
+
415
518
  // Check if any entities are child projects (nested stacks)
416
519
  const childProjects = new Map<string, ChildProjectInstance>();
417
520
  let hasChildProjects = false;
@@ -425,7 +528,7 @@ export const awsSerializer: Serializer = {
425
528
 
426
529
  // No nested stacks — use the simple path
427
530
  if (!hasChildProjects) {
428
- const template = serializeToTemplate(entities, outputs, undefined, undefined, ownership);
531
+ const template = serializeToTemplate(entities, outputs, undefined, undefined, ownership, receiptRows);
429
532
  return JSON.stringify(template, null, 2);
430
533
  }
431
534
 
@@ -465,7 +568,7 @@ export const awsSerializer: Serializer = {
465
568
  }
466
569
 
467
570
  // Serialize the parent template (ChildProjectInstance entities become CF::Stack resources)
468
- const parentTemplate = serializeToTemplate(entities, outputs, parentParams, undefined, ownership);
571
+ const parentTemplate = serializeToTemplate(entities, outputs, parentParams, undefined, ownership, receiptRows);
469
572
  const primary = JSON.stringify(parentTemplate, null, 2);
470
573
 
471
574
  return {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  skill: chant-aws-carve-terraform
3
- description: Demo carving a resource out of Terraform into native chant — advise, emit, bridge, apply — fully offline
3
+ description: Demo carving a resource out of Terraform into native chant — advise, emit, audit, bridge, apply — fully offline
4
4
  user-invocable: true
5
5
  ---
6
6
 
@@ -15,7 +15,7 @@ graduation.
15
15
  ## When to use
16
16
 
17
17
  - Someone asks "how does chant move things off Terraform?"
18
- - You want to show the advise → emit → bridge → apply loop end to end.
18
+ - You want to show the advise → emit → audit → bridge → apply loop end to end.
19
19
  - You are evaluating whether a Terraform estate is worth carving.
20
20
 
21
21
  ## Preconditions
@@ -27,7 +27,7 @@ graduation.
27
27
  ## The fastest path: run the bundled demo
28
28
 
29
29
  The `examples/terraform-carve-out` example ships a runnable estate + state and a
30
- script that runs all four steps with commentary:
30
+ script that runs all five steps with commentary:
31
31
 
32
32
  ```bash
33
33
  cd examples/terraform-carve-out
@@ -54,28 +54,30 @@ From `examples/terraform-carve-out`, with `TF=./terraform`:
54
54
  --state ./terraform/terraform.tfstate --output ./carveout
55
55
  ```
56
56
  Show `./carveout/src/assets.ts` — a real `new Bucket({ BucketName, Tags })` with
57
- CloudFormation-style properties mapped from the Terraform state attributes.
58
- Emit also scaffolds `./carveout` into a buildable chant project
59
- (`chant.config.ts`, `package.json`), so `npm install && npm run build` works
60
- there as-is. Explain: a Terraform-managed resource is not in any
61
- CloudFormation stack, so the correct source of its live shape is the state
62
- file, not a cloud read.
63
-
64
- 3. **Lint the inherited resource — optional, offer it.**
57
+ CloudFormation-style properties mapped from the Terraform state attributes,
58
+ and the `aws_s3_bucket_versioning` sub-resource folded into
59
+ `VersioningConfiguration`. Emit also scaffolds `./carveout` into a buildable
60
+ chant project (`chant.config.ts`, `package.json`) and persists a carve
61
+ manifest (`*.carve.json`) bridge and apply read the target from it, so
62
+ `--select` is only needed once. Explain: a Terraform-managed resource is not
63
+ in any CloudFormation stack, so the correct source of its live shape is the
64
+ state file, not a cloud read.
65
+
66
+ 3. **Audit the inherited resource.**
65
67
  ```bash
66
- chant lint ./carveout/src --lexicon aws
68
+ chant build ./carveout/src --lexicon aws
67
69
  ```
68
- After emit, offer to lint the carved source. chant audits the resource you
69
- inherited from Terraform against the AWS lexicon's rules. Findings have a
70
- severity: `error` (must fix before `chant build` will emit e.g. an S3
71
- bucket with no public-access block) and `warning`/`info` (advisory — e.g.
72
- DynamoDB point-in-time recovery). This is a feature of carving: chant
73
- immediately tells the person what is wrong with what they adopted. Whether to
74
- fix an advisory finding is their call; errors block the build until resolved.
70
+ The first build fails, deliberately: the post-synth audit refuses the
71
+ adopted bucket because Terraform managed it without a public-access block or
72
+ a TLS-only policy. This is a feature of carving: chant immediately tells the
73
+ person what is wrong with what they adopted. Show the fix (add
74
+ `PublicAccessBlockConfiguration` and a companion `S3BucketPolicy` with an
75
+ `aws:SecureTransport = false` Deny the tutorial page has the exact code),
76
+ then re-run the build and show the valid CloudFormation template.
75
77
 
76
78
  4. **Bridge — patch the surviving Terraform.**
77
79
  ```bash
78
- chant carve bridge --from ./terraform --select aws_s3_bucket.assets --output ./carveout
80
+ chant carve bridge --from ./terraform --output ./carveout
79
81
  ```
80
82
  Show the generated `data "aws_s3_bucket" "assets"` and the rewired survivor.
81
83
  Emphasize it is dry-run: nothing in `./terraform` changed. `--apply-rewrites`
@@ -83,11 +85,12 @@ From `examples/terraform-carve-out`, with `TF=./terraform`:
83
85
 
84
86
  5. **Apply — graduation plan.**
85
87
  ```bash
86
- chant carve apply --from ./terraform --select aws_s3_bucket.assets --env prod --stack assets
88
+ chant carve apply --from ./terraform --output ./carveout --env prod --stack assets
87
89
  ```
88
90
  Show the ownership marker (`chant:managed-by/stack/env`) and the finalized
89
- runbook. Stress this makes no cloud call the apply is the person's own
90
- lifecycle; chant just plans it.
91
+ runbook. `--write-source` additionally stamps the marker tags into the
92
+ emitted source. Stress this makes no cloud call — the apply is the person's
93
+ own lifecycle; chant just plans it.
91
94
 
92
95
  ## Key points to land
93
96