@intentius/chant 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 (223) hide show
  1. package/dist/audit/core.d.ts +21 -3
  2. package/dist/audit/core.d.ts.map +1 -1
  3. package/dist/audit/discover.d.ts +3 -2
  4. package/dist/audit/discover.d.ts.map +1 -1
  5. package/dist/audit/rules-doc.d.ts.map +1 -1
  6. package/dist/build.d.ts +3 -3
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/cli/commands/build.d.ts.map +1 -1
  9. package/dist/cli/commands/check-lexicon.d.ts +14 -0
  10. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  11. package/dist/cli/commands/lexicon-surface-diff.d.ts +6 -0
  12. package/dist/cli/commands/lexicon-surface-diff.d.ts.map +1 -1
  13. package/dist/cli/commands/lint.d.ts.map +1 -1
  14. package/dist/cli/handlers/lifecycle.d.ts +13 -0
  15. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  16. package/dist/cli/handlers/search.d.ts.map +1 -1
  17. package/dist/cli/main.d.ts.map +1 -1
  18. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  19. package/dist/cli/mcp/tools/explain.d.ts +6 -0
  20. package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
  21. package/dist/cli/plugins.d.ts +1 -1
  22. package/dist/cli/plugins.d.ts.map +1 -1
  23. package/dist/cli/registry.d.ts +7 -0
  24. package/dist/cli/registry.d.ts.map +1 -1
  25. package/dist/cli/reporters/stylish.d.ts +15 -1
  26. package/dist/cli/reporters/stylish.d.ts.map +1 -1
  27. package/dist/codegen/lexicon-regen.d.ts +11 -0
  28. package/dist/codegen/lexicon-regen.d.ts.map +1 -1
  29. package/dist/codegen/validate.d.ts +10 -0
  30. package/dist/codegen/validate.d.ts.map +1 -1
  31. package/dist/components/auto-release.d.ts +4 -0
  32. package/dist/components/auto-release.d.ts.map +1 -1
  33. package/dist/components/starter-plugin.d.ts +2 -0
  34. package/dist/components/starter-plugin.d.ts.map +1 -1
  35. package/dist/components/verbs/ensure-secret.d.ts +50 -0
  36. package/dist/components/verbs/ensure-secret.d.ts.map +1 -0
  37. package/dist/components/verbs/index.d.ts +8 -0
  38. package/dist/components/verbs/index.d.ts.map +1 -1
  39. package/dist/components/verbs/r2-sync.d.ts +76 -0
  40. package/dist/components/verbs/r2-sync.d.ts.map +1 -0
  41. package/dist/components/verbs/wrangler.d.ts +108 -0
  42. package/dist/components/verbs/wrangler.d.ts.map +1 -0
  43. package/dist/config.d.ts +54 -0
  44. package/dist/config.d.ts.map +1 -1
  45. package/dist/deep-observation.d.ts +14 -0
  46. package/dist/deep-observation.d.ts.map +1 -1
  47. package/dist/effect-receipt.d.ts +177 -0
  48. package/dist/effect-receipt.d.ts.map +1 -0
  49. package/dist/env.d.ts +12 -1
  50. package/dist/env.d.ts.map +1 -1
  51. package/dist/fold/subset.d.ts +15 -2
  52. package/dist/fold/subset.d.ts.map +1 -1
  53. package/dist/index.d.ts +4 -0
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/lexicon.d.ts +223 -2
  56. package/dist/lexicon.d.ts.map +1 -1
  57. package/dist/lifecycle/change-set.d.ts +33 -5
  58. package/dist/lifecycle/change-set.d.ts.map +1 -1
  59. package/dist/lifecycle/index.d.ts +3 -0
  60. package/dist/lifecycle/index.d.ts.map +1 -1
  61. package/dist/lifecycle/observation-baseline.d.ts +21 -3
  62. package/dist/lifecycle/observation-baseline.d.ts.map +1 -1
  63. package/dist/lifecycle/receipt-plan.d.ts +62 -0
  64. package/dist/lifecycle/receipt-plan.d.ts.map +1 -0
  65. package/dist/lifecycle/release-ledger.d.ts +20 -0
  66. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  67. package/dist/lifecycle/teardown.d.ts +132 -0
  68. package/dist/lifecycle/teardown.d.ts.map +1 -0
  69. package/dist/lifecycle/unobserved-gate.d.ts +67 -0
  70. package/dist/lifecycle/unobserved-gate.d.ts.map +1 -0
  71. package/dist/lint/engine.d.ts +6 -2
  72. package/dist/lint/engine.d.ts.map +1 -1
  73. package/dist/lint/knowledge-checks.d.ts +48 -0
  74. package/dist/lint/knowledge-checks.d.ts.map +1 -0
  75. package/dist/lint/output-checks.d.ts +5 -0
  76. package/dist/lint/output-checks.d.ts.map +1 -0
  77. package/dist/lint/pipeline-change-gate.d.ts +101 -0
  78. package/dist/lint/pipeline-change-gate.d.ts.map +1 -0
  79. package/dist/lint/post-synth.d.ts +12 -0
  80. package/dist/lint/post-synth.d.ts.map +1 -1
  81. package/dist/lint/receipt-checks.d.ts +9 -0
  82. package/dist/lint/receipt-checks.d.ts.map +1 -0
  83. package/dist/lint/rule.d.ts +31 -0
  84. package/dist/lint/rule.d.ts.map +1 -1
  85. package/dist/lint/rules/cor021-env-literal-name.d.ts +3 -0
  86. package/dist/lint/rules/cor021-env-literal-name.d.ts.map +1 -0
  87. package/dist/lint/rules/cor022-receipt-leaf.d.ts +13 -0
  88. package/dist/lint/rules/cor022-receipt-leaf.d.ts.map +1 -0
  89. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts +3 -0
  90. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts.map +1 -0
  91. package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
  92. package/dist/lint/rules/index.d.ts +4 -1
  93. package/dist/lint/rules/index.d.ts.map +1 -1
  94. package/dist/okf-read.d.ts +78 -0
  95. package/dist/okf-read.d.ts.map +1 -0
  96. package/dist/op/builders.d.ts +134 -8
  97. package/dist/op/builders.d.ts.map +1 -1
  98. package/dist/op/index.d.ts +4 -2
  99. package/dist/op/index.d.ts.map +1 -1
  100. package/dist/op/local-executor.d.ts +2 -1
  101. package/dist/op/local-executor.d.ts.map +1 -1
  102. package/dist/op/receipt-store.d.ts +138 -0
  103. package/dist/op/receipt-store.d.ts.map +1 -0
  104. package/dist/op/types.d.ts +31 -1
  105. package/dist/op/types.d.ts.map +1 -1
  106. package/dist/secret-materialization.d.ts +138 -0
  107. package/dist/secret-materialization.d.ts.map +1 -0
  108. package/dist/secret-provenance.d.ts +218 -0
  109. package/dist/secret-provenance.d.ts.map +1 -0
  110. package/dist/serializer.d.ts +11 -0
  111. package/dist/serializer.d.ts.map +1 -1
  112. package/dist/testing.d.ts +136 -0
  113. package/dist/testing.d.ts.map +1 -0
  114. package/dist/yaml.d.ts.map +1 -1
  115. package/package.json +9 -1
  116. package/src/audit/core.test.ts +57 -0
  117. package/src/audit/core.ts +0 -0
  118. package/src/audit/detect-bundle.test.ts +1 -1
  119. package/src/audit/discover.test.ts +24 -0
  120. package/src/audit/discover.ts +11 -2
  121. package/src/audit/rules-doc.ts +11 -1
  122. package/src/build.test.ts +41 -0
  123. package/src/build.ts +34 -6
  124. package/src/cli/commands/__fixtures__/audit-fountain/agents/fleet.yaml +27 -0
  125. package/src/cli/commands/__fixtures__/audit-fountain/k8s/deploy.yaml +16 -0
  126. package/src/cli/commands/__fixtures__/audit-fountain-clean/fleet.yaml +20 -0
  127. package/src/cli/commands/audit.test.ts +53 -0
  128. package/src/cli/commands/audit.ts +1 -1
  129. package/src/cli/commands/build.test.ts +211 -0
  130. package/src/cli/commands/build.ts +126 -8
  131. package/src/cli/commands/check-lexicon.test.ts +45 -1
  132. package/src/cli/commands/check-lexicon.ts +45 -0
  133. package/src/cli/commands/lexicon-surface-diff.ts +9 -0
  134. package/src/cli/commands/lexicon-surface-diff.update.test.ts +112 -0
  135. package/src/cli/commands/lint.ts +32 -7
  136. package/src/cli/handlers/explain.test.ts +70 -1
  137. package/src/cli/handlers/graph.ts +4 -4
  138. package/src/cli/handlers/lifecycle.test.ts +345 -1
  139. package/src/cli/handlers/lifecycle.ts +300 -11
  140. package/src/cli/handlers/search.ts +5 -2
  141. package/src/cli/main.ts +12 -1
  142. package/src/cli/mcp/resource-handlers.ts +38 -1
  143. package/src/cli/mcp/server.test.ts +58 -1
  144. package/src/cli/mcp/tools/explain.ts +51 -2
  145. package/src/cli/plugins.ts +4 -2
  146. package/src/cli/registry.ts +7 -0
  147. package/src/cli/reporters/stylish.test.ts +154 -0
  148. package/src/cli/reporters/stylish.ts +154 -33
  149. package/src/codegen/lexicon-regen.ts +19 -1
  150. package/src/codegen/validate.test.ts +33 -0
  151. package/src/codegen/validate.ts +21 -2
  152. package/src/components/auto-release.ts +6 -0
  153. package/src/components/registry.test.ts +7 -2
  154. package/src/components/starter-plugin.ts +17 -0
  155. package/src/components/verbs/ensure-secret.test.ts +130 -0
  156. package/src/components/verbs/ensure-secret.ts +79 -0
  157. package/src/components/verbs/index.ts +8 -0
  158. package/src/components/verbs/r2-sync.test.ts +107 -0
  159. package/src/components/verbs/r2-sync.ts +124 -0
  160. package/src/components/verbs/wrangler.test.ts +170 -0
  161. package/src/components/verbs/wrangler.ts +241 -0
  162. package/src/config.test.ts +55 -0
  163. package/src/config.ts +88 -1
  164. package/src/deep-observation.test.ts +19 -0
  165. package/src/deep-observation.ts +17 -0
  166. package/src/effect-receipt-exclusion.test.ts +190 -0
  167. package/src/effect-receipt.test.ts +419 -0
  168. package/src/effect-receipt.ts +412 -0
  169. package/src/env.test.ts +35 -1
  170. package/src/env.ts +17 -3
  171. package/src/fold/subset.test.ts +26 -0
  172. package/src/fold/subset.ts +45 -19
  173. package/src/index.ts +4 -0
  174. package/src/lexicon.ts +227 -2
  175. package/src/lifecycle/change-set.ts +46 -7
  176. package/src/lifecycle/index.ts +3 -0
  177. package/src/lifecycle/observation-baseline.test.ts +46 -0
  178. package/src/lifecycle/observation-baseline.ts +33 -1
  179. package/src/lifecycle/receipt-plan.test.ts +250 -0
  180. package/src/lifecycle/receipt-plan.ts +249 -0
  181. package/src/lifecycle/release-ledger.ts +20 -0
  182. package/src/lifecycle/teardown.test.ts +568 -0
  183. package/src/lifecycle/teardown.ts +359 -0
  184. package/src/lifecycle/unobserved-gate.test.ts +109 -0
  185. package/src/lifecycle/unobserved-gate.ts +102 -0
  186. package/src/lint/engine.ts +7 -1
  187. package/src/lint/knowledge-checks.test.ts +80 -0
  188. package/src/lint/knowledge-checks.ts +74 -0
  189. package/src/lint/output-checks.test.ts +85 -0
  190. package/src/lint/output-checks.ts +99 -0
  191. package/src/lint/pipeline-change-gate.test.ts +144 -0
  192. package/src/lint/pipeline-change-gate.ts +153 -0
  193. package/src/lint/post-synth.ts +15 -0
  194. package/src/lint/receipt-checks.test.ts +101 -0
  195. package/src/lint/receipt-checks.ts +93 -0
  196. package/src/lint/rule.ts +23 -0
  197. package/src/lint/rules/cor021-env-literal-name.test.ts +128 -0
  198. package/src/lint/rules/cor021-env-literal-name.ts +114 -0
  199. package/src/lint/rules/cor022-receipt-leaf.test.ts +116 -0
  200. package/src/lint/rules/cor022-receipt-leaf.ts +130 -0
  201. package/src/lint/rules/cor024-receipt-secret-pointer.test.ts +121 -0
  202. package/src/lint/rules/cor024-receipt-secret-pointer.ts +218 -0
  203. package/src/lint/rules/evl001-non-literal-expression.test.ts +27 -0
  204. package/src/lint/rules/evl001-non-literal-expression.ts +8 -1
  205. package/src/lint/rules/index.ts +10 -1
  206. package/src/okf-read.test.ts +149 -0
  207. package/src/okf-read.ts +197 -0
  208. package/src/op/builders.ts +179 -8
  209. package/src/op/effect-step.test.ts +311 -0
  210. package/src/op/index.ts +10 -3
  211. package/src/op/local-executor.ts +172 -25
  212. package/src/op/op.test.ts +25 -2
  213. package/src/op/receipt-store.ts +211 -0
  214. package/src/op/types.ts +33 -1
  215. package/src/secret-materialization.test.ts +199 -0
  216. package/src/secret-materialization.ts +235 -0
  217. package/src/secret-provenance.test.ts +388 -0
  218. package/src/secret-provenance.ts +475 -0
  219. package/src/serializer.ts +12 -0
  220. package/src/testing.test.ts +261 -0
  221. package/src/testing.ts +338 -0
  222. package/src/yaml.test.ts +88 -0
  223. package/src/yaml.ts +76 -6
@@ -18,6 +18,8 @@ import {
18
18
  import { computeBuildDigest, diffDigests } from "../../lifecycle/digest";
19
19
  import { diffLive, diffLiveArtifacts, diffSnapshots, type LiveDiffResult, type LiveArtifactDiffResult, type SnapshotDiffResult } from "../../lifecycle/live-diff";
20
20
  import { buildChangeSet, renderChangeSet, gitlabMrReport, summarize, type ChangeSet } from "../../lifecycle/change-set";
21
+ import { mergeReceiptEntries, observedValueResolver, planReceipts, readReceiptValue, type ReceiptReading } from "../../lifecycle/receipt-plan";
22
+ import { collectEffectReceipts, isEffectReceipt } from "../../effect-receipt";
21
23
  import {
22
24
  formatUnobserved,
23
25
  mergeObservations,
@@ -30,7 +32,9 @@ import { discoverComponents } from "../../components/discover";
30
32
  import { cfnDeployStacks } from "./components";
31
33
  import { affectedStacks } from "../../lifecycle/affected";
32
34
  import { rollbackToRevision } from "../../lifecycle/rollback";
33
- import { loadChantConfig, environmentNames } from "../../config";
35
+ import { loadChantConfig, environmentNames, matchesDeclaredEnvironment, resolveOwnershipStack } from "../../config";
36
+ import { unknownEnvError, isProdLikeEnvironment } from "../../env";
37
+ import { planTeardown, executeTeardown, type TeardownPlan, type TeardownReport } from "../../lifecycle/teardown";
34
38
  import { collectBuildRootContributors } from "../plugins";
35
39
  import { applyLiveEndpoint } from "../../live-endpoint";
36
40
  import { isResourceDeclarable } from "../../declarable";
@@ -109,7 +113,7 @@ export async function runLifecycleSnapshot(ctx: CommandContext): Promise<number>
109
113
  const declaredParams = await commandBuildParams(config.buildParams, args);
110
114
  if (!declaredParams) return 1;
111
115
  const declaredEnvNames = environmentNames(config.environments);
112
- if (declaredEnvNames && !declaredEnvNames.includes(environment)) {
116
+ if (declaredEnvNames && !matchesDeclaredEnvironment(config.environments, environment)) {
113
117
  console.error(formatError({
114
118
  message: `Unknown environment "${environment}"`,
115
119
  hint: `Defined environments: ${declaredEnvNames.join(", ")}`,
@@ -356,6 +360,10 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
356
360
  // committed set. Absent is the normal state (nothing accepted yet).
357
361
  const baseline = args.live ? await readObservationBaseline(environment) : null;
358
362
  const accepted: Record<string, DeviationToAccept[]> = {};
363
+ // Effect receipts across every target's build (#1833) — `--update-baseline`
364
+ // refuses to accept drift on them (the effect step is their only writer),
365
+ // and recognition is marker-based so materialized lexicon rows are caught.
366
+ const receiptEntities = new Set<string>();
359
367
 
360
368
  // #1166 — an environment can declare its own endpoint (a local emulator like
361
369
  // Floci), so `--live` is self-sufficient even when the ambient shell never
@@ -428,6 +436,9 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
428
436
  for (const [lexicon, deviations] of Object.entries(r.toAccept)) {
429
437
  (accepted[lexicon] ??= []).push(...deviations);
430
438
  }
439
+ if (args.updateBaseline) {
440
+ for (const name of collectEffectReceipts(buildResult.entities).keys()) receiptEntities.add(name);
441
+ }
431
442
  if (json) {
432
443
  if (target.stack) perStackJson[target.stack] = r.byLexicon;
433
444
  else combinedLexiconsJson = r.byLexicon;
@@ -441,7 +452,7 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
441
452
  // accepted, so it stops re-alerting. Runs before the summary lines so the
442
453
  // "no drift" verdict below still describes the run that produced it.
443
454
  if (args.live && args.updateBaseline) {
444
- await recordAcceptedBaseline(environment, baseline, accepted, json);
455
+ await recordAcceptedBaseline(environment, baseline, accepted, json, receiptEntities);
445
456
  }
446
457
 
447
458
  if (args.live) {
@@ -493,6 +504,7 @@ async function recordAcceptedBaseline(
493
504
  existing: ObservationBaseline | null,
494
505
  accepted: Record<string, DeviationToAccept[]>,
495
506
  json: boolean,
507
+ receipts?: ReadonlySet<string>,
496
508
  ): Promise<void> {
497
509
  const total = Object.values(accepted).reduce((n, d) => n + d.length, 0);
498
510
  if (total === 0) {
@@ -503,11 +515,14 @@ async function recordAcceptedBaseline(
503
515
  }
504
516
  return;
505
517
  }
506
- let next = existing ?? emptyBaseline(environment);
507
- for (const [lexicon, deviations] of Object.entries(accepted)) {
508
- next = acceptDeviations(next, lexicon, deviations);
509
- }
510
518
  try {
519
+ // `acceptDeviations` throws on an effect-receipt deviation (#1833) —
520
+ // inside the try so the refusal reaches the operator as a formatted
521
+ // error, with no baseline written at all.
522
+ let next = existing ?? emptyBaseline(environment);
523
+ for (const [lexicon, deviations] of Object.entries(accepted)) {
524
+ next = acceptDeviations(next, lexicon, deviations, { receipts });
525
+ }
511
526
  await writeObservationBaseline(next);
512
527
  const pushed = await pushLifecycle();
513
528
  if (!json) {
@@ -518,7 +533,7 @@ async function recordAcceptedBaseline(
518
533
  }
519
534
  } catch (err) {
520
535
  console.error(formatError({
521
- message: `--update-baseline: could not write the baseline — ${err instanceof Error ? err.message : String(err)}`,
536
+ message: `--update-baseline: baseline not updated — ${err instanceof Error ? err.message : String(err)}`,
522
537
  }));
523
538
  }
524
539
  }
@@ -1124,6 +1139,15 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
1124
1139
  const merged: ChangeSet = { env: environment, entries: [] };
1125
1140
  let checked = 0;
1126
1141
 
1142
+ // Effect receipts (#1832): declared, diffed, and observed like any resource,
1143
+ // but observe-only to the generic apply path — the plan compares live value
1144
+ // to resolved expectation and proposes the fire, never a create/update.
1145
+ const receipts = collectEffectReceipts(buildResult.entities);
1146
+ const receiptReadings = new Map<string, ReceiptReading>();
1147
+ // Every lexicon's observed resources merged, for resolving a receipt's
1148
+ // reference inputs against observed values / stack outputs at plan time.
1149
+ const allObservedResources: Record<string, ResourceMetadata> = {};
1150
+
1127
1151
  // #1166 — same self-sufficiency as `chant graph --live`: an environment can
1128
1152
  // declare its own endpoint, applied here unless the ambient shell already
1129
1153
  // set it. `chant lifecycle plan` is always a live read (no `--live` flag of
@@ -1154,11 +1178,14 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
1154
1178
  const declared = new Set<string>();
1155
1179
  const entities = new Map<string, { entityType: string; props: Record<string, unknown> }>();
1156
1180
  for (const [name, entity] of buildResult.entities) {
1157
- if (entity.lexicon === lexiconName && isResourceDeclarable(entity)) {
1181
+ // A receipt has no `props` payload of its own but is declared, diffed,
1182
+ // and observed like any resource (#1832) — it joins the declared axis
1183
+ // so its lexicon's observation can confirm presence or absence.
1184
+ if (entity.lexicon === lexiconName && (isResourceDeclarable(entity) || isEffectReceipt(entity))) {
1158
1185
  declared.add(name);
1159
1186
  entities.set(name, {
1160
1187
  entityType: entity.entityType,
1161
- props: (entity.props != null ? entity.props : {}) as Record<string, unknown>,
1188
+ props: (isResourceDeclarable(entity) && entity.props != null ? entity.props : {}) as Record<string, unknown>,
1162
1189
  });
1163
1190
  }
1164
1191
  }
@@ -1183,6 +1210,37 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
1183
1210
  ...(args.namespace ? { namespace: args.namespace } : {}),
1184
1211
  });
1185
1212
 
1213
+ Object.assign(allObservedResources, observed.resources);
1214
+ for (const [receiptName, receipt] of receipts) {
1215
+ if (receipt.lexicon !== lexiconName) continue;
1216
+ const live = observed.resources[receiptName];
1217
+ const hole = observed.unobserved[receiptName];
1218
+ if (live) {
1219
+ receiptReadings.set(receiptName, {
1220
+ observed: true,
1221
+ present: true,
1222
+ value: readReceiptValue(live.attributes),
1223
+ type: live.type,
1224
+ ...(live.physicalId ? { physicalId: live.physicalId } : {}),
1225
+ lexicon: lexiconName,
1226
+ });
1227
+ } else if (hole) {
1228
+ receiptReadings.set(receiptName, {
1229
+ observed: false,
1230
+ present: false,
1231
+ lexicon: lexiconName,
1232
+ ...(hole.type ? { type: hole.type } : {}),
1233
+ unobservedReason: hole.reason,
1234
+ ...(hole.detail ? { unobservedDetail: hole.detail } : {}),
1235
+ });
1236
+ } else {
1237
+ // Neither returned nor named unobserved: the lexicon looked and
1238
+ // confirmed the receipt absent — the same claim the change set
1239
+ // reads off a bare observation (#1089).
1240
+ receiptReadings.set(receiptName, { observed: true, present: false, lexicon: lexiconName });
1241
+ }
1242
+ }
1243
+
1186
1244
  const content = await readSnapshot(environment, lexiconName);
1187
1245
  const observedThen = content ? (JSON.parse(content) as LifecycleSnapshot).resources : undefined;
1188
1246
 
@@ -1212,6 +1270,16 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
1212
1270
  return 1;
1213
1271
  }
1214
1272
 
1273
+ // Receipt classification (#1832): live value vs resolved expectation.
1274
+ // Absent/stale proposes the fire; unobservable is a loud hole; whatever the
1275
+ // generic classification proposed for a receipt (a create, a delete) is
1276
+ // replaced — the effect step is the sole writer, and a receipt is never a
1277
+ // prune candidate.
1278
+ if (receipts.size > 0) {
1279
+ const receiptEntries = planReceipts(receipts, receiptReadings, observedValueResolver(allObservedResources));
1280
+ mergeReceiptEntries(merged, receipts, receiptEntries);
1281
+ }
1282
+
1215
1283
  merged.entries.sort((a, b) => a.name.localeCompare(b.name));
1216
1284
 
1217
1285
  // Say it on stderr too, so `--json` and `--report gitlab-mr` consumers (whose
@@ -1240,6 +1308,227 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
1240
1308
  return 0;
1241
1309
  }
1242
1310
 
1311
+ /**
1312
+ * chant lifecycle teardown <environment> (#1222).
1313
+ *
1314
+ * Enumerates what deleting the environment would remove: every live resource
1315
+ * carrying this project's ownership marker (managed-by + `ownership.stack`)
1316
+ * with the requested env identity. Stateless — live markers only, no build, no
1317
+ * snapshot. Without `--yes` nothing is deleted; with it the planned set is
1318
+ * executed per lexicon with one bounded retry pass over failures, and every
1319
+ * candidate's outcome is reported (deleted / failed / not-prunable / retained / skipped —
1320
+ * never silence). A production-like environment name additionally requires an
1321
+ * interactive confirmation, or `--confirm-prod` non-interactively.
1322
+ */
1323
+ export async function runLifecycleTeardown(ctx: CommandContext): Promise<number> {
1324
+ const { args, plugins } = ctx;
1325
+ const environment = args.extraPositional;
1326
+
1327
+ if (!environment) {
1328
+ console.error(formatError({ message: "Environment is required: chant lifecycle teardown <environment>" }));
1329
+ return 1;
1330
+ }
1331
+
1332
+ const { config } = await loadChantConfig(resolve("."));
1333
+
1334
+ // Refuse an env the project does not declare — a typo here is the difference
1335
+ // between tearing down `dev` and tearing down `prod`. Literal `environments`
1336
+ // entries only (#1221's pattern entries would extend this same check).
1337
+ const envErr = unknownEnvError(environment, config.environments);
1338
+ if (envErr) {
1339
+ console.error(formatError({ message: envErr }));
1340
+ return 1;
1341
+ }
1342
+
1343
+ // Teardown selects on the ownership marker; a project that stamps none has
1344
+ // nothing to key on, and "delete what looks like mine" is not a fallback.
1345
+ const stack = resolveOwnershipStack(config);
1346
+ if (stack === undefined) {
1347
+ console.error(formatError({
1348
+ message: "This project declares no ownership.stack — teardown is marker-scoped and has nothing to select on",
1349
+ hint: 'Set `ownership: { stack: "<name>" }` in chant.config.ts and deploy, so resources carry the marker teardown keys on.',
1350
+ }));
1351
+ return 1;
1352
+ }
1353
+
1354
+ // The prod guard (#1222): a production-like name never falls to `--yes`
1355
+ // alone. Interactive runs re-type the environment name; non-interactive
1356
+ // runs say `--confirm-prod` explicitly. Checked before any live read so a
1357
+ // refused teardown touches nothing at all.
1358
+ if (args.yes && isProdLikeEnvironment(environment) && !args.confirmProd) {
1359
+ if (!process.stdin.isTTY) {
1360
+ console.error(formatError({
1361
+ message: `"${environment}" looks like a production environment — --yes alone is not enough`,
1362
+ hint: "Re-run with --yes --confirm-prod to tear it down non-interactively.",
1363
+ }));
1364
+ return 1;
1365
+ }
1366
+ const confirmed = await promptProdTeardown(environment);
1367
+ if (!confirmed) {
1368
+ console.error(formatError({ message: "Confirmation did not match — nothing was deleted." }));
1369
+ return 1;
1370
+ }
1371
+ }
1372
+
1373
+ // #1166 — teardown is always a live read (and with --yes, a live write), so
1374
+ // an environment's declared endpoint applies here too, unless the ambient
1375
+ // shell already set it.
1376
+ const readingPlugins = plugins.filter((p) => p.teardownOwned || p.describeResources || p.executeTeardown);
1377
+ const endpointResult = applyLiveEndpoint(config.environments, environment, readingPlugins);
1378
+ if (endpointResult.notice) console.error(formatWarning({ message: endpointResult.notice }));
1379
+
1380
+ let plan: TeardownPlan;
1381
+ let report: TeardownReport | undefined;
1382
+ try {
1383
+ // A multi-stack project's declared stacks, for stack-shaped teardowns
1384
+ // (aws enumerates and deletes whole CloudFormation stacks). A single-stack
1385
+ // project passes nothing and the env-named default convention applies.
1386
+ const deployedStacks = (config.stacks ?? []).map((s) => ({
1387
+ name: s.name,
1388
+ ...(s.region ? { region: s.region } : {}),
1389
+ }));
1390
+ plan = await planTeardown({
1391
+ environment,
1392
+ stack,
1393
+ plugins,
1394
+ ...(deployedStacks.length > 0 ? { deployedStacks } : {}),
1395
+ });
1396
+ if (args.yes) {
1397
+ report = await executeTeardown({
1398
+ environment,
1399
+ stack,
1400
+ plugins,
1401
+ plan,
1402
+ ...(deployedStacks.length > 0 ? { deployedStacks } : {}),
1403
+ });
1404
+ }
1405
+ } finally {
1406
+ endpointResult.restore();
1407
+ }
1408
+
1409
+ if (args.json) {
1410
+ console.log(JSON.stringify(report ?? plan, null, 2));
1411
+ return report && report.outcomes.some((o) => o.outcome === "failed") ? 1 : 0;
1412
+ }
1413
+
1414
+ console.log(formatBold(
1415
+ args.yes
1416
+ ? `Teardown — environment: ${environment}, stack: ${stack}`
1417
+ : `Teardown plan — environment: ${environment}, stack: ${stack} (plan only — nothing is deleted)`,
1418
+ ));
1419
+
1420
+ if (plan.entries.length === 0) {
1421
+ console.error(formatWarning({
1422
+ message: `No live resources carry the marker stack "${stack}" + env "${environment}" — nothing would be deleted.` +
1423
+ (plan.holes.length > 0
1424
+ ? " But this plan has holes (below) — parts of the estate could not be read, so \"nothing\" is a claim about what was readable, not about the environment."
1425
+ : " If this environment is deployed, check that its resources were stamped (ownership marking on, and the env identity resolved at build time)."),
1426
+ }));
1427
+ } else {
1428
+ console.log(`\n${plan.entries.length} resource(s) would be deleted:`);
1429
+ console.log("RESOURCE".padEnd(28) + "TYPE".padEnd(32) + "MARKER".padEnd(24) + "LEXICON");
1430
+ console.log("-".repeat(96));
1431
+ for (const entry of plan.entries) {
1432
+ console.log(
1433
+ entry.name.padEnd(28) +
1434
+ entry.type.padEnd(32) +
1435
+ `${entry.marker.stack}/${entry.marker.env}`.padEnd(24) +
1436
+ entry.lexicon,
1437
+ );
1438
+ }
1439
+ }
1440
+
1441
+ // Holes are loud (#1089): an unreadable kind is unknown, not absent, and the
1442
+ // execution half must not treat this plan as the whole delete set.
1443
+ if (plan.holes.length > 0) {
1444
+ console.error(formatWarning({
1445
+ message: `${plan.holes.length} hole(s) — resources chant may own but could not read. This plan is incomplete, not clean.`,
1446
+ }));
1447
+ console.log(formatBold("\nHOLES (stamped kinds the read could not cover):"));
1448
+ for (const hole of plan.holes) {
1449
+ console.log(` ? ${hole.lexicon}: ${hole.name}${hole.type ? ` (${hole.type})` : ""} — ${hole.reason}${hole.detail ? `: ${hole.detail}` : ""}`);
1450
+ }
1451
+ }
1452
+
1453
+ if (plan.skipped.length > 0) {
1454
+ console.error(formatWarning({
1455
+ message: `Skipped (no teardown enumeration and no describeResources): ${plan.skipped.join(", ")} — those lexicons' resources are not in this plan.`,
1456
+ }));
1457
+ }
1458
+
1459
+ if (!report) {
1460
+ console.error(formatWarning({ message: "Plan only — re-run with --yes to execute." }));
1461
+ return 0;
1462
+ }
1463
+
1464
+ if (report.outcomes.length > 0) {
1465
+ console.log(formatBold("\nOutcomes:"));
1466
+ console.log("RESOURCE".padEnd(28) + "OUTCOME".padEnd(14) + "LEXICON".padEnd(12) + "DETAIL");
1467
+ console.log("-".repeat(96));
1468
+ for (const o of report.outcomes) {
1469
+ console.log(
1470
+ o.name.padEnd(28) +
1471
+ o.outcome.padEnd(14) +
1472
+ o.lexicon.padEnd(12) +
1473
+ (o.detail ?? "") +
1474
+ (o.retried ? " (after retry)" : ""),
1475
+ );
1476
+ }
1477
+ }
1478
+
1479
+ const counts = { deleted: 0, failed: 0, "not-prunable": 0, retained: 0, skipped: 0 };
1480
+ for (const o of report.outcomes) counts[o.outcome]++;
1481
+ console.log(
1482
+ `\n${counts.deleted} deleted, ${counts.failed} failed, ${counts["not-prunable"]} not prunable, ` +
1483
+ `${counts.retained} retained, ${counts.skipped} skipped`,
1484
+ );
1485
+
1486
+ if (report.unimplemented.length > 0) {
1487
+ console.error(formatWarning({
1488
+ message: `Not executed (no teardown execution in these lexicons yet): ${report.unimplemented.join(", ")} — their candidates are reported as skipped, not deleted.`,
1489
+ }));
1490
+ }
1491
+ if (counts.retained > 0) {
1492
+ console.error(formatWarning({
1493
+ message: `${counts.retained} resource(s) retained — owned by this env but deliberately kept (generated-once secrets are never swept). ` +
1494
+ `The environment is NOT clean while they exist; delete them explicitly (e.g. kubectl delete) if you mean to.`,
1495
+ }));
1496
+ }
1497
+ if (plan.holes.length > 0) {
1498
+ console.error(formatWarning({
1499
+ message: "This teardown ran over an incomplete plan (holes above) — the environment cannot be called clean.",
1500
+ }));
1501
+ }
1502
+ if (counts.failed > 0) {
1503
+ console.error(formatError({
1504
+ message: `${counts.failed} candidate(s) failed to delete after the retry pass — see the outcomes above.`,
1505
+ }));
1506
+ return 1;
1507
+ }
1508
+
1509
+ return 0;
1510
+ }
1511
+
1512
+ /**
1513
+ * The interactive half of the prod guard: the operator re-types the
1514
+ * environment name. Anything else — including EOF — refuses.
1515
+ */
1516
+ async function promptProdTeardown(environment: string): Promise<boolean> {
1517
+ const { createInterface } = await import("node:readline");
1518
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1519
+ return new Promise((resolvePrompt) => {
1520
+ rl.question(
1521
+ `"${environment}" looks like a production environment. Type the environment name to confirm teardown: `,
1522
+ (answer) => {
1523
+ resolvePrompt(answer.trim() === environment);
1524
+ rl.close();
1525
+ },
1526
+ );
1527
+ // EOF (Ctrl-D) closes the interface without answering — that is a refusal.
1528
+ rl.on("close", () => resolvePrompt(false));
1529
+ });
1530
+ }
1531
+
1243
1532
  /**
1244
1533
  * chant lifecycle log [environment]
1245
1534
  */
@@ -1268,7 +1557,7 @@ export async function runLifecycleLog(ctx: CommandContext): Promise<number> {
1268
1557
  export async function runLifecycleUnknown(ctx: CommandContext): Promise<number> {
1269
1558
  console.error(formatError({
1270
1559
  message: `Unknown state subcommand: ${ctx.args.extraPositional ?? ctx.args.path}`,
1271
- hint: "Available: chant lifecycle snapshot, chant lifecycle show, chant lifecycle diff, chant lifecycle plan, chant lifecycle log",
1560
+ hint: "Available: chant lifecycle snapshot, chant lifecycle show, chant lifecycle diff, chant lifecycle plan, chant lifecycle teardown, chant lifecycle log",
1272
1561
  }));
1273
1562
  return 1;
1274
1563
  }
@@ -9,7 +9,7 @@ import { discover } from "../../discovery/index";
9
9
  import { observeResources } from "../../lifecycle/observe";
10
10
  import { replaySnapshots, hasSnapshot } from "../../lifecycle/replay";
11
11
  import type { LiveObservation } from "../../graph-ir";
12
- import { loadChantConfig } from "../../config";
12
+ import { loadChantConfig, matchesDeclaredEnvironment } from "../../config";
13
13
  import { loadPlugins, resolveProjectLexicons } from "../plugins";
14
14
  import { formatError, formatWarning } from "../format";
15
15
  import type { CommandContext } from "../registry";
@@ -89,7 +89,10 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
89
89
  }));
90
90
  return 1;
91
91
  }
92
- if (config.environments && !config.environments.includes(environment)) {
92
+ // Membership via matchesDeclaredEnvironment (#1221): handles `{ name,
93
+ // endpoint }` entries (#1166) — a plain `.includes` never matched those —
94
+ // and glob-pattern entries like `"pr-*"`.
95
+ if (config.environments && config.environments.length > 0 && !matchesDeclaredEnvironment(config.environments, environment)) {
93
96
  console.error(formatError({ message: `Unknown environment "${environment}"` }));
94
97
  return 1;
95
98
  }
package/src/cli/main.ts CHANGED
@@ -23,7 +23,7 @@ import { runCarveAdvise, runCarveUnknown } from "./handlers/carve";
23
23
  import { runCarveEmit } from "./handlers/carve-emit";
24
24
  import { runCarveBridge } from "./handlers/carve-bridge";
25
25
  import { runCarveApply } from "./handlers/carve-apply";
26
- import { runLifecycleSnapshot, runLifecycleShow, runLifecycleDiff, runLifecycleRollback, runLifecyclePlan, runLifecycleAffected, runLifecycleLog, runLifecycleUnknown } from "./handlers/lifecycle";
26
+ import { runLifecycleSnapshot, runLifecycleShow, runLifecycleDiff, runLifecycleRollback, runLifecyclePlan, runLifecycleAffected, runLifecycleLog, runLifecycleTeardown, runLifecycleUnknown } from "./handlers/lifecycle";
27
27
  import { runComponentsStatus, runComponentsReleaseRecord, runComponentsUnknown } from "./handlers/components";
28
28
  import { runGraph } from "./handlers/graph";
29
29
  import { runExplain } from "./handlers/explain";
@@ -78,6 +78,8 @@ const BOOLEAN_FLAGS = new Set([
78
78
  "--fold",
79
79
  "--no-fold",
80
80
  "--sandbox",
81
+ "--yes",
82
+ "--confirm-prod",
81
83
  ]);
82
84
 
83
85
  /**
@@ -228,6 +230,10 @@ export function parseArgs(args: string[]): ParsedArgs {
228
230
  result.emit = args[++i];
229
231
  } else if (arg === "--dry-run") {
230
232
  result.dryRun = true;
233
+ } else if (arg === "--yes") {
234
+ result.yes = true;
235
+ } else if (arg === "--confirm-prod") {
236
+ result.confirmProd = true;
231
237
  } else if (arg === "--strict") {
232
238
  result.strict = true;
233
239
  } else if (arg === "--validate") {
@@ -484,6 +490,10 @@ Lifecycle (alias: lc):
484
490
  lifecycle plan <env> Typed change set (create/update/delete/adopt) vs live
485
491
  lifecycle affected Stacks a change affects (--base <ref> [--include-dependents])
486
492
  --json: emit the ChangeSet as JSON
493
+ lifecycle teardown <env> Plan what deleting the environment would remove —
494
+ marker-scoped (this project's stack + env); --yes
495
+ executes the plan (production-like names also need
496
+ --confirm-prod, or an interactive confirmation)
487
497
  lifecycle log [env] History of lifecycle snapshots
488
498
 
489
499
  Component release ledger + status:
@@ -780,6 +790,7 @@ const registry: CommandDef[] = [
780
790
  { name: "lifecycle rollback", handler: runLifecycleRollback },
781
791
  { name: "lifecycle plan", requiresPlugins: true, handler: runLifecyclePlan },
782
792
  { name: "lifecycle affected", requiresPlugins: true, handler: runLifecycleAffected },
793
+ { name: "lifecycle teardown", requiresPlugins: true, handler: runLifecycleTeardown },
783
794
  { name: "lifecycle log", handler: runLifecycleLog },
784
795
 
785
796
  // Component release ledger + status surface (#568, epic #551)
@@ -1,10 +1,13 @@
1
- import { resolve } from "node:path";
1
+ import { resolve, join, dirname } from "node:path";
2
+ import { readFile } from "node:fs/promises";
2
3
  import type { ResourceDefinition } from "./types";
3
4
  import { getContext } from "./resources/context";
4
5
  import { readSnapshot, readEnvironmentSnapshots } from "../../lifecycle/git";
5
6
  import { discoverOps } from "../../op/discover";
6
7
  import { makeTemporalClient } from "../handlers/run";
7
8
  import { resolveWorkflowId } from "../handlers/run-client";
9
+ import { loadOkfBundle } from "../../okf-read";
10
+ import { loadChantConfigUpward, resolveKnowledgeDir } from "../../config";
8
11
 
9
12
  type PluginResourceEntry = { definition: ResourceDefinition; handler: () => Promise<string> };
10
13
 
@@ -42,6 +45,12 @@ export const coreResourceDefinitions: ResourceDefinition[] = [
42
45
  description: "Latest run state for a named Op",
43
46
  mimeType: "application/json",
44
47
  },
48
+ {
49
+ uri: "chant://knowledge",
50
+ name: "Knowledge bundle",
51
+ description: "The project's OKF knowledge bundle (#1864, #1059): index.md and every authored concept, empty when no bundle exists",
52
+ mimeType: "application/json",
53
+ },
45
54
  {
46
55
  uri: "chant://state/{environment}",
47
56
  name: "State (all lexicons)",
@@ -122,6 +131,34 @@ export async function handleResourcesRead(
122
131
  };
123
132
  }
124
133
 
134
+ // Authored knowledge (#1867, #1864, design #1059): same bundle the CLI's
135
+ // `chant explain` reads, over MCP — the resource path has no local
136
+ // filesystem, so this is the only way an MCP client sees the `knowledge/`
137
+ // directory's contents at all. Resolved from cwd, matching every other
138
+ // path-less resource here (`chant://ops`, `chant://state/...`). A missing
139
+ // bundle is not an error — `loadOkfBundle` already treats it as empty, and
140
+ // a missing `index.md` reads as `index: null` rather than throwing.
141
+ if (uri === "chant://knowledge") {
142
+ const cwd = resolve(".");
143
+ const loaded = await loadChantConfigUpward(cwd);
144
+ const projectRoot = loaded.configPath ? dirname(loaded.configPath) : cwd;
145
+ const dir = resolveKnowledgeDir(loaded.config, projectRoot);
146
+ const bundle = await loadOkfBundle(dir);
147
+ let index: string | null = null;
148
+ try {
149
+ index = await readFile(join(dir, "index.md"), "utf8");
150
+ } catch {
151
+ index = null;
152
+ }
153
+ return {
154
+ contents: [{
155
+ uri,
156
+ mimeType: "application/json",
157
+ text: JSON.stringify({ dir, index, concepts: bundle.concepts }, null, 2),
158
+ }],
159
+ };
160
+ }
161
+
125
162
  // Op resources
126
163
  if (uri === "chant://ops") {
127
164
  const { ops } = await discoverOps();
@@ -590,6 +590,7 @@ describe("McpServer", () => {
590
590
  expect(uris).toContain("chant://ops");
591
591
  expect(uris).toContain("chant://ops/{name}/runs");
592
592
  expect(uris).toContain("chant://ops/{name}/runs/latest");
593
+ expect(uris).toContain("chant://knowledge");
593
594
 
594
595
  // Each resource has required fields
595
596
  for (const resource of result.resources) {
@@ -735,6 +736,62 @@ describe("McpServer", () => {
735
736
  expect(data.error).toBeDefined();
736
737
  });
737
738
  });
739
+
740
+ describe("chant://knowledge", () => {
741
+ test("empty gracefully when no bundle exists", async () => {
742
+ const originalCwd = process.cwd();
743
+ process.chdir(testDir);
744
+ try {
745
+ const response = await server.handleRequest({
746
+ jsonrpc: "2.0",
747
+ id: 1,
748
+ method: "resources/read",
749
+ params: { uri: "chant://knowledge" },
750
+ });
751
+ expect(response.error).toBeUndefined();
752
+ const result = response.result as { contents: Array<{ text: string; mimeType: string }> };
753
+ expect(result.contents[0].mimeType).toBe("application/json");
754
+ const data = JSON.parse(result.contents[0].text);
755
+ expect(data.index).toBeNull();
756
+ expect(data.concepts).toEqual([]);
757
+ } finally {
758
+ process.chdir(originalCwd);
759
+ }
760
+ });
761
+
762
+ test("serves the bundle index and concepts when present", async () => {
763
+ const originalCwd = process.cwd();
764
+ process.chdir(testDir);
765
+ try {
766
+ await mkdir(join(testDir, "knowledge", "decisions"), { recursive: true });
767
+ await writeFile(join(testDir, "knowledge", "index.md"), "# Knowledge index\n");
768
+ await writeFile(
769
+ join(testDir, "knowledge", "decisions", "public-assets.md"),
770
+ "---\ntype: decision\ntitle: Public assets\nbinds: bucket\n---\nBody text.\n",
771
+ );
772
+
773
+ const response = await server.handleRequest({
774
+ jsonrpc: "2.0",
775
+ id: 1,
776
+ method: "resources/read",
777
+ params: { uri: "chant://knowledge" },
778
+ });
779
+ expect(response.error).toBeUndefined();
780
+ const result = response.result as { contents: Array<{ text: string }> };
781
+ const data = JSON.parse(result.contents[0].text);
782
+ expect(data.index).toBe("# Knowledge index\n");
783
+ expect(data.concepts).toHaveLength(1);
784
+ expect(data.concepts[0]).toMatchObject({
785
+ path: "decisions/public-assets.md",
786
+ type: "decision",
787
+ title: "Public assets",
788
+ binds: ["bucket"],
789
+ });
790
+ } finally {
791
+ process.chdir(originalCwd);
792
+ }
793
+ });
794
+ });
738
795
  });
739
796
 
740
797
  // -----------------------------------------------------------------------
@@ -1157,7 +1214,7 @@ describe("McpServer", () => {
1157
1214
 
1158
1215
  const resourcesRes = await s.handleRequest({ jsonrpc: "2.0", id: 3, method: "resources/list" });
1159
1216
  const resources = (resourcesRes.result as { resources: Array<{ uri: string }> }).resources;
1160
- expect(resources).toHaveLength(7);
1217
+ expect(resources).toHaveLength(8);
1161
1218
  });
1162
1219
 
1163
1220
  test("server with empty plugins array works", async () => {