@intentius/chant 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 (183) 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/lint.d.ts.map +1 -1
  10. package/dist/cli/handlers/lifecycle.d.ts +1 -1
  11. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  12. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  13. package/dist/cli/mcp/tools/explain.d.ts +6 -0
  14. package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
  15. package/dist/cli/plugins.d.ts +1 -1
  16. package/dist/cli/plugins.d.ts.map +1 -1
  17. package/dist/cli/reporters/stylish.d.ts +15 -1
  18. package/dist/cli/reporters/stylish.d.ts.map +1 -1
  19. package/dist/components/auto-release.d.ts +4 -0
  20. package/dist/components/auto-release.d.ts.map +1 -1
  21. package/dist/components/starter-plugin.d.ts +2 -0
  22. package/dist/components/starter-plugin.d.ts.map +1 -1
  23. package/dist/components/verbs/ensure-secret.d.ts +50 -0
  24. package/dist/components/verbs/ensure-secret.d.ts.map +1 -0
  25. package/dist/components/verbs/index.d.ts +8 -0
  26. package/dist/components/verbs/index.d.ts.map +1 -1
  27. package/dist/components/verbs/r2-sync.d.ts +76 -0
  28. package/dist/components/verbs/r2-sync.d.ts.map +1 -0
  29. package/dist/components/verbs/wrangler.d.ts +108 -0
  30. package/dist/components/verbs/wrangler.d.ts.map +1 -0
  31. package/dist/config.d.ts +26 -0
  32. package/dist/config.d.ts.map +1 -1
  33. package/dist/deep-observation.d.ts +14 -0
  34. package/dist/deep-observation.d.ts.map +1 -1
  35. package/dist/effect-receipt.d.ts +177 -0
  36. package/dist/effect-receipt.d.ts.map +1 -0
  37. package/dist/fold/subset.d.ts +15 -2
  38. package/dist/fold/subset.d.ts.map +1 -1
  39. package/dist/index.d.ts +4 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/lexicon.d.ts +44 -3
  42. package/dist/lexicon.d.ts.map +1 -1
  43. package/dist/lifecycle/change-set.d.ts +33 -5
  44. package/dist/lifecycle/change-set.d.ts.map +1 -1
  45. package/dist/lifecycle/index.d.ts +2 -0
  46. package/dist/lifecycle/index.d.ts.map +1 -1
  47. package/dist/lifecycle/observation-baseline.d.ts +21 -3
  48. package/dist/lifecycle/observation-baseline.d.ts.map +1 -1
  49. package/dist/lifecycle/receipt-plan.d.ts +62 -0
  50. package/dist/lifecycle/receipt-plan.d.ts.map +1 -0
  51. package/dist/lifecycle/release-ledger.d.ts +20 -0
  52. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  53. package/dist/lifecycle/teardown.d.ts +6 -4
  54. package/dist/lifecycle/teardown.d.ts.map +1 -1
  55. package/dist/lifecycle/unobserved-gate.d.ts +67 -0
  56. package/dist/lifecycle/unobserved-gate.d.ts.map +1 -0
  57. package/dist/lint/knowledge-checks.d.ts +48 -0
  58. package/dist/lint/knowledge-checks.d.ts.map +1 -0
  59. package/dist/lint/output-checks.d.ts +5 -0
  60. package/dist/lint/output-checks.d.ts.map +1 -0
  61. package/dist/lint/pipeline-change-gate.d.ts +101 -0
  62. package/dist/lint/pipeline-change-gate.d.ts.map +1 -0
  63. package/dist/lint/post-synth.d.ts +12 -0
  64. package/dist/lint/post-synth.d.ts.map +1 -1
  65. package/dist/lint/receipt-checks.d.ts +9 -0
  66. package/dist/lint/receipt-checks.d.ts.map +1 -0
  67. package/dist/lint/rules/cor022-receipt-leaf.d.ts +13 -0
  68. package/dist/lint/rules/cor022-receipt-leaf.d.ts.map +1 -0
  69. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts +3 -0
  70. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts.map +1 -0
  71. package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
  72. package/dist/lint/rules/index.d.ts +3 -1
  73. package/dist/lint/rules/index.d.ts.map +1 -1
  74. package/dist/okf-read.d.ts +78 -0
  75. package/dist/okf-read.d.ts.map +1 -0
  76. package/dist/op/builders.d.ts +98 -1
  77. package/dist/op/builders.d.ts.map +1 -1
  78. package/dist/op/index.d.ts +4 -2
  79. package/dist/op/index.d.ts.map +1 -1
  80. package/dist/op/local-executor.d.ts +2 -1
  81. package/dist/op/local-executor.d.ts.map +1 -1
  82. package/dist/op/receipt-store.d.ts +138 -0
  83. package/dist/op/receipt-store.d.ts.map +1 -0
  84. package/dist/op/types.d.ts +31 -1
  85. package/dist/op/types.d.ts.map +1 -1
  86. package/dist/secret-materialization.d.ts +138 -0
  87. package/dist/secret-materialization.d.ts.map +1 -0
  88. package/dist/secret-provenance.d.ts +218 -0
  89. package/dist/secret-provenance.d.ts.map +1 -0
  90. package/dist/serializer.d.ts +11 -0
  91. package/dist/serializer.d.ts.map +1 -1
  92. package/dist/yaml.d.ts.map +1 -1
  93. package/package.json +4 -1
  94. package/src/audit/core.test.ts +57 -0
  95. package/src/audit/core.ts +0 -0
  96. package/src/audit/detect-bundle.test.ts +1 -1
  97. package/src/audit/discover.test.ts +24 -0
  98. package/src/audit/discover.ts +11 -2
  99. package/src/audit/rules-doc.ts +11 -1
  100. package/src/build.test.ts +41 -0
  101. package/src/build.ts +34 -6
  102. package/src/cli/commands/__fixtures__/audit-fountain/agents/fleet.yaml +27 -0
  103. package/src/cli/commands/__fixtures__/audit-fountain/k8s/deploy.yaml +16 -0
  104. package/src/cli/commands/__fixtures__/audit-fountain-clean/fleet.yaml +20 -0
  105. package/src/cli/commands/audit.test.ts +53 -0
  106. package/src/cli/commands/audit.ts +1 -1
  107. package/src/cli/commands/build.test.ts +80 -0
  108. package/src/cli/commands/build.ts +106 -8
  109. package/src/cli/commands/lint.ts +15 -3
  110. package/src/cli/handlers/explain.test.ts +70 -1
  111. package/src/cli/handlers/graph.ts +2 -2
  112. package/src/cli/handlers/lifecycle.test.ts +115 -1
  113. package/src/cli/handlers/lifecycle.ts +84 -11
  114. package/src/cli/mcp/resource-handlers.ts +38 -1
  115. package/src/cli/mcp/server.test.ts +58 -1
  116. package/src/cli/mcp/tools/explain.ts +51 -2
  117. package/src/cli/plugins.ts +4 -2
  118. package/src/cli/reporters/stylish.test.ts +154 -0
  119. package/src/cli/reporters/stylish.ts +154 -33
  120. package/src/components/auto-release.ts +6 -0
  121. package/src/components/registry.test.ts +7 -2
  122. package/src/components/starter-plugin.ts +17 -0
  123. package/src/components/verbs/ensure-secret.test.ts +130 -0
  124. package/src/components/verbs/ensure-secret.ts +79 -0
  125. package/src/components/verbs/index.ts +8 -0
  126. package/src/components/verbs/r2-sync.test.ts +107 -0
  127. package/src/components/verbs/r2-sync.ts +124 -0
  128. package/src/components/verbs/wrangler.test.ts +170 -0
  129. package/src/components/verbs/wrangler.ts +241 -0
  130. package/src/config.test.ts +15 -0
  131. package/src/config.ts +30 -0
  132. package/src/deep-observation.test.ts +19 -0
  133. package/src/deep-observation.ts +17 -0
  134. package/src/effect-receipt-exclusion.test.ts +190 -0
  135. package/src/effect-receipt.test.ts +419 -0
  136. package/src/effect-receipt.ts +412 -0
  137. package/src/fold/subset.test.ts +26 -0
  138. package/src/fold/subset.ts +45 -19
  139. package/src/index.ts +4 -0
  140. package/src/lexicon.ts +48 -3
  141. package/src/lifecycle/change-set.ts +46 -7
  142. package/src/lifecycle/index.ts +2 -0
  143. package/src/lifecycle/observation-baseline.test.ts +46 -0
  144. package/src/lifecycle/observation-baseline.ts +33 -1
  145. package/src/lifecycle/receipt-plan.test.ts +250 -0
  146. package/src/lifecycle/receipt-plan.ts +249 -0
  147. package/src/lifecycle/release-ledger.ts +20 -0
  148. package/src/lifecycle/teardown.test.ts +31 -0
  149. package/src/lifecycle/teardown.ts +6 -4
  150. package/src/lifecycle/unobserved-gate.test.ts +109 -0
  151. package/src/lifecycle/unobserved-gate.ts +102 -0
  152. package/src/lint/knowledge-checks.test.ts +80 -0
  153. package/src/lint/knowledge-checks.ts +74 -0
  154. package/src/lint/output-checks.test.ts +85 -0
  155. package/src/lint/output-checks.ts +99 -0
  156. package/src/lint/pipeline-change-gate.test.ts +144 -0
  157. package/src/lint/pipeline-change-gate.ts +153 -0
  158. package/src/lint/post-synth.ts +15 -0
  159. package/src/lint/receipt-checks.test.ts +101 -0
  160. package/src/lint/receipt-checks.ts +93 -0
  161. package/src/lint/rules/cor022-receipt-leaf.test.ts +116 -0
  162. package/src/lint/rules/cor022-receipt-leaf.ts +130 -0
  163. package/src/lint/rules/cor024-receipt-secret-pointer.test.ts +121 -0
  164. package/src/lint/rules/cor024-receipt-secret-pointer.ts +218 -0
  165. package/src/lint/rules/evl001-non-literal-expression.test.ts +27 -0
  166. package/src/lint/rules/evl001-non-literal-expression.ts +8 -1
  167. package/src/lint/rules/index.ts +7 -1
  168. package/src/okf-read.test.ts +149 -0
  169. package/src/okf-read.ts +197 -0
  170. package/src/op/builders.ts +139 -1
  171. package/src/op/effect-step.test.ts +311 -0
  172. package/src/op/index.ts +10 -3
  173. package/src/op/local-executor.ts +172 -25
  174. package/src/op/op.test.ts +25 -2
  175. package/src/op/receipt-store.ts +211 -0
  176. package/src/op/types.ts +33 -1
  177. package/src/secret-materialization.test.ts +199 -0
  178. package/src/secret-materialization.ts +235 -0
  179. package/src/secret-provenance.test.ts +388 -0
  180. package/src/secret-provenance.ts +475 -0
  181. package/src/serializer.ts +12 -0
  182. package/src/yaml.test.ts +88 -0
  183. package/src/yaml.ts +76 -6
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Receipt planning (#1832, epic #1703) — the plan half of the effect-receipt
3
+ * contract.
4
+ *
5
+ * A receipt is declared, diffed, and observed like any resource, but it is
6
+ * OBSERVE-ONLY to the generic apply path: the `effect()` step (#1834) is the
7
+ * sole writer, on success, last (epic decision log, item 3). The plan's job is
8
+ * therefore not "create the receipt" — it is "say whether the effect will
9
+ * fire". This module compares each declared receipt's live value against its
10
+ * resolved expectation and classifies:
11
+ *
12
+ * - absent or differing → an `effect` entry ("effect will fire") — including
13
+ * the crash-between-effect-and-write case, where a stale or missing receipt
14
+ * re-proposes the fire on the next plan. That re-proposal is the whole
15
+ * at-least-once guarantee; anything that stamps the receipt on the generic
16
+ * path silently converts it into never.
17
+ * - equal → `noop` — the effect has run for these inputs; the plan is clean.
18
+ * - unobservable → `unobserved`, loudly. A receipt nobody could read is a
19
+ * hole in the plan, never a clean row.
20
+ * - a reference input that cannot resolve at plan time → an `effect` entry
21
+ * with an "unresolved input" note, never a guessed digest. The effect step
22
+ * resolves again at run and decides there.
23
+ *
24
+ * A receipt name is also stripped out of whatever the generic change-set
25
+ * classification proposed for it ({@link mergeReceiptEntries}): a receipt is
26
+ * never a `create`, never an `update`, and never a `delete`/`adopt` prune
27
+ * candidate — the entries built here are the only rows a receipt gets.
28
+ *
29
+ * Pure: no I/O. Live state arrives as {@link ReceiptReading}s the caller
30
+ * built from its observations; reference inputs resolve through the
31
+ * caller-supplied resolver ({@link observedValueResolver} builds one over the
32
+ * plan's merged observed values).
33
+ */
34
+ import { AttrRef } from "../attrref";
35
+ import {
36
+ EXISTENCE_EXPECTATION,
37
+ resolveReceiptExpectation,
38
+ type EffectReceiptDeclaration,
39
+ type ReceiptInputResolver,
40
+ } from "../effect-receipt";
41
+ import type { ResourceMetadata } from "../lexicon";
42
+ import type { UnobservedReason } from "../observation";
43
+ import type { ChangeSet, ChangeSetEntry } from "./change-set";
44
+
45
+ /**
46
+ * The attribute a materialized receipt's live value is read from. A lexicon
47
+ * receipt row's observation (#1835) maps the stored value onto
48
+ * `attributes[RECEIPT_VALUE_ATTRIBUTE]`; `Value` is accepted as a fallback for
49
+ * observations that keep the provider's own casing (SSM's `Value`).
50
+ */
51
+ export const RECEIPT_VALUE_ATTRIBUTE = "value";
52
+
53
+ /** Read a live receipt's stored value off an observation's attributes. */
54
+ export function readReceiptValue(attributes: Record<string, unknown> | undefined): unknown {
55
+ if (!attributes) return undefined;
56
+ if (RECEIPT_VALUE_ATTRIBUTE in attributes) return attributes[RECEIPT_VALUE_ATTRIBUTE];
57
+ return attributes["Value"];
58
+ }
59
+
60
+ /**
61
+ * What the plan's observation pass learned about one declared receipt.
62
+ * Deliberately tri-state, the same shape the change set rests on (#1089):
63
+ * `observed: false` means "nobody looked", which is a hole — never absence.
64
+ * A receipt with no reading at all means no loaded lexicon even claims it.
65
+ */
66
+ export interface ReceiptReading {
67
+ /** The lexicon actually looked. `false` → the receipt is a plan hole. */
68
+ observed: boolean;
69
+ /** Observed present in the live system. Meaningful only when `observed`. */
70
+ present: boolean;
71
+ /** The live receipt's stored value ({@link readReceiptValue}). */
72
+ value?: unknown;
73
+ /** Live resource type, when reported. */
74
+ type?: string;
75
+ /** Provider-assigned physical id, when reported. */
76
+ physicalId?: string;
77
+ /** The lexicon whose observation produced this reading. */
78
+ lexicon?: string;
79
+ /** Why the read did not happen, when `observed: false`. */
80
+ unobservedReason?: UnobservedReason;
81
+ unobservedDetail?: string;
82
+ }
83
+
84
+ /**
85
+ * A {@link ReceiptInputResolver} over the plan's merged observed values: an
86
+ * attr-ref resolves to the referenced entity's observed attribute. Anything
87
+ * that is not among the observed values throws — which `planReceipts` renders
88
+ * as an effect-will-fire entry with an "unresolved input" note rather than a
89
+ * guessed expectation.
90
+ */
91
+ export function observedValueResolver(
92
+ resources: Readonly<Record<string, ResourceMetadata>>,
93
+ ): ReceiptInputResolver {
94
+ return (ref, path) => {
95
+ if (ref instanceof AttrRef) {
96
+ const entity = ref.getLogicalName();
97
+ if (!entity) {
98
+ throw new Error(`unresolved input at ${path}: attr-ref carries no logical name`);
99
+ }
100
+ const attrs = resources[entity]?.attributes;
101
+ if (!attrs || !(ref.attribute in attrs)) {
102
+ throw new Error(
103
+ `unresolved input at ${path}: ${entity}.${ref.attribute} is not among the observed values`,
104
+ );
105
+ }
106
+ return attrs[ref.attribute];
107
+ }
108
+ throw new Error(`unresolved input at ${path}: reference cannot resolve at plan time`);
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Classify every declared receipt into its change-set entry. See the module
114
+ * doc for the classification; entries come back sorted by name.
115
+ */
116
+ export function planReceipts(
117
+ receipts: ReadonlyMap<string, EffectReceiptDeclaration>,
118
+ readings: ReadonlyMap<string, ReceiptReading>,
119
+ resolver: ReceiptInputResolver,
120
+ ): ChangeSetEntry[] {
121
+ const entries: ChangeSetEntry[] = [];
122
+
123
+ for (const [name, receipt] of receipts) {
124
+ const reading = readings.get(name);
125
+ const base = {
126
+ name,
127
+ type: reading?.type ?? receipt.entityType,
128
+ ...(reading?.lexicon ? { lexicon: reading.lexicon } : {}),
129
+ ...(reading?.physicalId ? { physicalId: reading.physicalId } : {}),
130
+ effect: receipt.effect,
131
+ ownership: "unknown" as const,
132
+ };
133
+ const evidence = {
134
+ declared: true,
135
+ inSnapshot: false,
136
+ live: reading?.present ?? false,
137
+ observed: reading?.observed ?? false,
138
+ };
139
+
140
+ if (!reading) {
141
+ // No loaded lexicon materializes or observes this receipt — a hole,
142
+ // said loudly, never a silently clean row (#1089's tri-state).
143
+ entries.push({
144
+ ...base,
145
+ action: "unobserved",
146
+ evidence,
147
+ unobservedReason: "unsupported-kind",
148
+ unobservedDetail:
149
+ `no loaded lexicon materializes or observes this receipt — ` +
150
+ `whether effect "${receipt.effect}" has run is unknown, not clean`,
151
+ });
152
+ continue;
153
+ }
154
+
155
+ if (!reading.observed) {
156
+ entries.push({
157
+ ...base,
158
+ action: "unobserved",
159
+ evidence,
160
+ unobservedReason: reading.unobservedReason ?? "read-failed",
161
+ ...(reading.unobservedDetail ? { unobservedDetail: reading.unobservedDetail } : {}),
162
+ });
163
+ continue;
164
+ }
165
+
166
+ // Resolve the expectation. A reference that cannot resolve at plan time
167
+ // is never guessed around: the fire is proposed with the note, and the
168
+ // effect step resolves again at run (epic decision log, item 5).
169
+ let expectation: string | undefined;
170
+ let unresolved: string | undefined;
171
+ try {
172
+ expectation = resolveReceiptExpectation(receipt, resolver);
173
+ } catch (err) {
174
+ unresolved = err instanceof Error ? err.message : String(err);
175
+ }
176
+
177
+ if (!reading.present) {
178
+ entries.push({
179
+ ...base,
180
+ action: "effect",
181
+ evidence,
182
+ effectReason: "receipt-absent",
183
+ effectDetail:
184
+ `receipt confirmed absent — no run of "${receipt.effect}" is recorded` +
185
+ (unresolved ? ` (${unresolved})` : ""),
186
+ });
187
+ continue;
188
+ }
189
+
190
+ if (expectation === undefined) {
191
+ entries.push({
192
+ ...base,
193
+ action: "effect",
194
+ evidence,
195
+ effectReason: "unresolved-input",
196
+ effectDetail: unresolved,
197
+ });
198
+ continue;
199
+ }
200
+
201
+ if (reading.value === expectation) {
202
+ entries.push({ ...base, action: "noop", evidence });
203
+ continue;
204
+ }
205
+
206
+ entries.push({
207
+ ...base,
208
+ action: "effect",
209
+ evidence,
210
+ effectReason: "receipt-stale",
211
+ effectDetail:
212
+ receipt.flavor === "existence"
213
+ ? `live value ${fmtValue(reading.value)} is not the existence marker "${EXISTENCE_EXPECTATION}"`
214
+ : `live value ${fmtValue(reading.value)} differs from expected ${expectation}`,
215
+ });
216
+ }
217
+
218
+ entries.sort((a, b) => a.name.localeCompare(b.name));
219
+ return entries;
220
+ }
221
+
222
+ /**
223
+ * Replace whatever the generic classification proposed for the receipts with
224
+ * the receipt entries built by {@link planReceipts}, in place.
225
+ *
226
+ * This is the plan-side write-exclusion (#1832): the generic change set,
227
+ * knowing nothing about receipts, classifies a declared-but-absent receipt as
228
+ * `create` and an undeclared-but-live one as `delete`/`adopt` — proposals the
229
+ * generic apply path must never act on for a receipt, and prune candidacy a
230
+ * receipt must never have. Every entry named like a receipt is dropped and
231
+ * the receipt's own entries stand in.
232
+ */
233
+ export function mergeReceiptEntries(
234
+ cs: ChangeSet,
235
+ receipts: ReadonlyMap<string, EffectReceiptDeclaration>,
236
+ receiptEntries: ChangeSetEntry[],
237
+ ): ChangeSet {
238
+ if (receipts.size === 0) return cs;
239
+ cs.entries = cs.entries.filter((e) => !receipts.has(e.name));
240
+ cs.entries.push(...receiptEntries);
241
+ cs.entries.sort((a, b) => a.name.localeCompare(b.name));
242
+ return cs;
243
+ }
244
+
245
+ function fmtValue(v: unknown): string {
246
+ if (v === undefined) return "<unset>";
247
+ const s = typeof v === "string" ? v : JSON.stringify(v);
248
+ return s.length > 72 ? `${s.slice(0, 69)}...` : s;
249
+ }
@@ -78,6 +78,26 @@ export interface ReleaseRecord {
78
78
  approver?: string;
79
79
  /** Optional: the archive's own manifest digest (../components/verbs/build-archive.ts's `manifestDigest`), when the caller has it — lets a reader recover full build contents/provenance, not just the promoted image digest. */
80
80
  manifestDigest?: string;
81
+ /**
82
+ * Optional: set when the deploy's capability-profile assertion (chant
83
+ * #1244, helm lexicon) was deliberately overridden — carries the named
84
+ * divergences that were bypassed (declared vs live), so the ledger shows
85
+ * this release knowingly skewed from its declared profile. Absent for a
86
+ * deploy whose target matched, and for deploys with no declared profile.
87
+ */
88
+ profileOverride?: string;
89
+ /**
90
+ * Optional: the deploy's input-side digest, when `digest` is a
91
+ * rendered-content identity rather than an input identity. A pinned helm
92
+ * deploy (chant #1242) records the render's `contentDigest` as `digest` —
93
+ * what this cluster actually received — and carries the input digest
94
+ * (chart, chart version, resolved values, capability facts; chant #1243)
95
+ * here, because profiles are per cluster: two environments legitimately
96
+ * render to different bytes, so cross-environment "is prod running what
97
+ * staging tested" joins on this field while `digest` proves the exact
98
+ * bytes each cluster got. Absent when `digest` is already input-side.
99
+ */
100
+ inputDigest?: string;
81
101
  }
82
102
 
83
103
  /** Required, non-empty-string fields every `ReleaseRecord` must carry. */
@@ -281,6 +281,37 @@ describe("executeTeardown — the execution half (#1222)", () => {
281
281
  expect(report.unimplemented).toEqual([]);
282
282
  });
283
283
 
284
+ test("a retained outcome (#1830) passes through as the loud keep — reported, never retried, never counted failed", async () => {
285
+ const candidateSecret = { name: "prod/master-key", type: "K8s::Core::Secret", marker: { stack: "shop", env: "dev" } };
286
+ const execute = vi.fn(async (): Promise<TeardownExecution> => ({
287
+ outcomes: [
288
+ { name: "prod/master-key", outcome: "retained", detail: "generated-once secret — deliberately kept, never swept" },
289
+ ],
290
+ }));
291
+ const report = await executeTeardown({
292
+ environment: "dev",
293
+ stack: "shop",
294
+ plugins: [
295
+ createMockPlugin({
296
+ name: "k8s",
297
+ teardownOwned: async () => ({ candidates: [candidateSecret] }),
298
+ executeTeardown: execute,
299
+ }),
300
+ ],
301
+ });
302
+
303
+ // One pass only: retained is a verdict, not a failure to retry.
304
+ expect(execute).toHaveBeenCalledTimes(1);
305
+ expect(report.outcomes).toEqual([
306
+ {
307
+ lexicon: "k8s",
308
+ ...candidateSecret,
309
+ outcome: "retained",
310
+ detail: "generated-once secret — deliberately kept, never swept",
311
+ },
312
+ ]);
313
+ });
314
+
284
315
  test("reuses a handed-in plan instead of re-reading", async () => {
285
316
  const teardownOwned = vi.fn();
286
317
  const execute = vi.fn(async (): Promise<TeardownExecution> => ({
@@ -191,11 +191,13 @@ export async function planTeardown(opts: PlanTeardownOptions): Promise<TeardownP
191
191
  export interface TeardownOutcomeEntry extends TeardownPlanEntry {
192
192
  /**
193
193
  * `skipped` is core's verdict for a candidate whose lexicon implements no
194
- * `executeTeardown` yet; the other three come from the lexicon (see
195
- * {@link TeardownOutcome}).
194
+ * `executeTeardown` yet; the others come from the lexicon (see
195
+ * {@link TeardownOutcome}). `retained` is the loud keep (#1365 decision 5):
196
+ * owned, no longer declared, deliberately not deleted — a `generated-once`
197
+ * secret survives every sweep and says so with a row.
196
198
  */
197
- outcome: "deleted" | "failed" | "not-prunable" | "skipped";
198
- /** The error for `failed`, the reason for `not-prunable`/`skipped`. */
199
+ outcome: "deleted" | "failed" | "not-prunable" | "retained" | "skipped";
200
+ /** The error for `failed`, the reason for `not-prunable`/`retained`/`skipped`. */
199
201
  detail?: string;
200
202
  /** True when this final outcome came from the bounded retry pass. */
201
203
  retried?: boolean;
@@ -0,0 +1,109 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { buildChangeSet } from "./change-set";
3
+ import { evaluateUnobservedGate } from "./unobserved-gate";
4
+ import type { ChangeSet } from "./change-set";
5
+ import type { UnobservedReason } from "../observation";
6
+
7
+ const cleanChangeSet = (env = "prod"): ChangeSet =>
8
+ buildChangeSet(env, {
9
+ declared: new Set(["bucket"]),
10
+ observedNow: { bucket: { type: "Fake::Bucket", status: "OK" } },
11
+ observedThen: undefined,
12
+ });
13
+
14
+ const unobservedChangeSet = (reason: UnobservedReason, detail?: string): ChangeSet =>
15
+ buildChangeSet("prod", {
16
+ declared: new Set(["queue"]),
17
+ observedNow: {},
18
+ observedThen: undefined,
19
+ unobserved: { queue: { reason, ...(detail ? { detail } : {}) } },
20
+ });
21
+
22
+ describe("evaluateUnobservedGate (#1568)", () => {
23
+ test("a clean change set passes under every policy", () => {
24
+ const cs = cleanChangeSet();
25
+ expect(evaluateUnobservedGate(cs)).toEqual({ pass: true, escalate: false, findings: [] });
26
+ expect(evaluateUnobservedGate(cs, "escalate")).toEqual({ pass: true, escalate: false, findings: [] });
27
+ expect(evaluateUnobservedGate(cs, { allow: [] })).toEqual({ pass: true, escalate: false, findings: [] });
28
+ });
29
+
30
+ test("default policy (no argument) refuses a plan with an unobserved entity", () => {
31
+ const cs = unobservedChangeSet("no-credentials", "aws sts get-caller-identity: access denied");
32
+ const verdict = evaluateUnobservedGate(cs);
33
+ expect(verdict.pass).toBe(false);
34
+ expect(verdict.escalate).toBe(false);
35
+ expect(verdict.findings).toEqual([
36
+ { name: "queue", reason: "no-credentials", detail: "aws sts get-caller-identity: access denied" },
37
+ ]);
38
+ expect(verdict.detail).toContain("queue");
39
+ expect(verdict.detail).toContain("no credentials");
40
+ });
41
+
42
+ test('explicit "refuse" behaves the same as the default', () => {
43
+ const cs = unobservedChangeSet("read-failed");
44
+ expect(evaluateUnobservedGate(cs, "refuse").pass).toBe(false);
45
+ });
46
+
47
+ test('"escalate" does not fail the gate but flags escalate with the findings', () => {
48
+ const cs = unobservedChangeSet("no-binding");
49
+ const verdict = evaluateUnobservedGate(cs, "escalate");
50
+ expect(verdict.pass).toBe(true);
51
+ expect(verdict.escalate).toBe(true);
52
+ expect(verdict.findings).toHaveLength(1);
53
+ expect(verdict.findings[0].reason).toBe("no-binding");
54
+ expect(verdict.detail).toBeDefined();
55
+ });
56
+
57
+ test("an allowed reason passes cleanly and is not counted as a finding", () => {
58
+ const cs = unobservedChangeSet("filtered");
59
+ const verdict = evaluateUnobservedGate(cs, { allow: ["filtered"] });
60
+ expect(verdict).toEqual({ pass: true, escalate: false, findings: [] });
61
+ });
62
+
63
+ test("a reason outside the allow list still refuses", () => {
64
+ const cs = unobservedChangeSet("no-credentials");
65
+ const verdict = evaluateUnobservedGate(cs, { allow: ["filtered"] });
66
+ expect(verdict.pass).toBe(false);
67
+ expect(verdict.findings[0].reason).toBe("no-credentials");
68
+ });
69
+
70
+ test("a mixed change set with one allowed and one unallowed reason only reports the unallowed one", () => {
71
+ const cs = buildChangeSet("prod", {
72
+ declared: new Set(["a", "b"]),
73
+ observedNow: {},
74
+ observedThen: undefined,
75
+ unobserved: {
76
+ a: { reason: "filtered" },
77
+ b: { reason: "unsupported-kind" },
78
+ },
79
+ });
80
+ const verdict = evaluateUnobservedGate(cs, { allow: ["filtered"] });
81
+ expect(verdict.pass).toBe(false);
82
+ expect(verdict.findings).toEqual([{ name: "b", reason: "unsupported-kind" }]);
83
+ });
84
+
85
+ test("never escalates or refuses on create/update/delete/adopt/noop — only `unobserved` counts", () => {
86
+ const cs = buildChangeSet("prod", {
87
+ declared: new Set(["created", "updated"]),
88
+ observedNow: {
89
+ updated: { type: "Fake::Resource", status: "DRIFTED" },
90
+ orphan: { type: "Fake::Resource", status: "OK" },
91
+ },
92
+ observedThen: { updated: { type: "Fake::Resource", status: "OK" } },
93
+ });
94
+ expect(evaluateUnobservedGate(cs)).toEqual({ pass: true, escalate: false, findings: [] });
95
+ });
96
+
97
+ test("carries type and detail through into the finding when present", () => {
98
+ const cs = buildChangeSet("prod", {
99
+ declared: new Set(["disk"]),
100
+ observedNow: {},
101
+ observedThen: undefined,
102
+ unobserved: { disk: { reason: "unsupported-kind", detail: "no describe support for this kind yet", type: "Fake::Disk" } },
103
+ });
104
+ const verdict = evaluateUnobservedGate(cs);
105
+ expect(verdict.findings).toEqual([
106
+ { name: "disk", type: "Fake::Disk", reason: "unsupported-kind", detail: "no describe support for this kind yet" },
107
+ ]);
108
+ });
109
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The unobserved gate predicate (#1568): a plan containing unobserved
3
+ * entities must not pass as clean.
4
+ *
5
+ * `buildChangeSet` (./change-set.ts) already refuses to guess when a lexicon
6
+ * could not look (#1089) — entries land as `action: "unobserved"`, carrying a
7
+ * total `unobservedReason` (`read-failed`, `no-credentials`, `no-binding`,
8
+ * `unsupported-kind`, `filtered`; see ../observation.ts). But nothing
9
+ * downstream is *required* to look: a gate that checks lint-clean,
10
+ * owned-only, and a blast-radius budget can walk right past a change set
11
+ * whose live read partially failed and call it clean, because none of those
12
+ * checks reads `unobserved` at all. A gate over an incomplete read is a gate
13
+ * over a guess.
14
+ *
15
+ * This is a reusable predicate, not a runtime — evaluate a `ChangeSet`
16
+ * against a policy and get back a verdict; the caller (a `lint.policies`
17
+ * check routed through `policyGate`, an Op step, #1487's gate-as-fact once it
18
+ * lands) decides what to do with a refusal (throw, diagnostic, a durable
19
+ * gated-run fact). Same instinct #1484 already adopts for `unknown` in the
20
+ * operating loop ("`unknown` never remediates"), surfaced here as something
21
+ * any gate can declare rather than a property of one runtime.
22
+ */
23
+ import type { ChangeSet } from "./change-set";
24
+ import { unobservedReasonText, type UnobservedReason } from "../observation";
25
+
26
+ /**
27
+ * How the gate treats the change set's unobserved set.
28
+ *
29
+ * - `"refuse"` (the default) — any unobserved entry not covered by `allow`
30
+ * fails the gate outright. Fail-closed, the right default for a policy set
31
+ * aimed at agent-proposed change sets: a hole must never launder into a
32
+ * clean verdict just because nobody configured otherwise.
33
+ * - `"escalate"` — the gate does not fail here; instead the verdict reports
34
+ * `escalate: true` so a caller can route the plan to a stricter gate class
35
+ * rather than the ordinary path (the same shape of routing decision #1569
36
+ * makes for pipeline changes).
37
+ * - `{ allow: [...] }` — the listed reasons are tolerated (e.g. `"filtered"`,
38
+ * intentional in a scoped run) and never fail or escalate the gate; any
39
+ * reason NOT in the list still refuses. `no-credentials` — a hole that must
40
+ * never launder into a clean verdict — should never appear in an `allow`
41
+ * list for a policy aimed at agent-proposed change sets.
42
+ */
43
+ export type UnobservedGatePolicy = "refuse" | "escalate" | { allow: UnobservedReason[] };
44
+
45
+ /** One unobserved hole the gate found, carried into the refusal. */
46
+ export interface UnobservedGateFinding {
47
+ name: string;
48
+ type?: string;
49
+ reason: UnobservedReason;
50
+ detail?: string;
51
+ }
52
+
53
+ export interface UnobservedGateVerdict {
54
+ /** False when the policy is `"refuse"` (default) and an unallowed unobserved entry exists. */
55
+ pass: boolean;
56
+ /** True when the policy is `"escalate"` and an unallowed unobserved entry exists — route to a stricter gate instead of failing here. */
57
+ escalate: boolean;
58
+ /** Every unobserved entry the policy did not allow. Empty when `pass` is true and `escalate` is false. */
59
+ findings: UnobservedGateFinding[];
60
+ /** Human-readable summary carrying the reasons into a refusal — the message a caller can surface as-is. Undefined when `findings` is empty. */
61
+ detail?: string;
62
+ }
63
+
64
+ /**
65
+ * Evaluate a `ChangeSet`'s unobserved set against `policy`. Pure — reads only
66
+ * `cs.entries`, no I/O. Defaults to `"refuse"`.
67
+ */
68
+ export function evaluateUnobservedGate(
69
+ cs: ChangeSet,
70
+ policy: UnobservedGatePolicy = "refuse",
71
+ ): UnobservedGateVerdict {
72
+ const allowed = typeof policy === "object" ? new Set(policy.allow) : undefined;
73
+
74
+ const findings: UnobservedGateFinding[] = [];
75
+ for (const e of cs.entries) {
76
+ if (e.action !== "unobserved" || e.unobservedReason === undefined) continue;
77
+ if (allowed?.has(e.unobservedReason)) continue;
78
+ findings.push({
79
+ name: e.name,
80
+ ...(e.type ? { type: e.type } : {}),
81
+ reason: e.unobservedReason,
82
+ ...(e.unobservedDetail ? { detail: e.unobservedDetail } : {}),
83
+ });
84
+ }
85
+
86
+ if (findings.length === 0) {
87
+ return { pass: true, escalate: false, findings: [] };
88
+ }
89
+
90
+ const detail =
91
+ `plan contains ${findings.length} unobserved ${findings.length === 1 ? "entity" : "entities"} — ` +
92
+ findings
93
+ .map((f) => `${f.name}${f.type ? ` (${f.type})` : ""}: ${unobservedReasonText(f.reason)}${f.detail ? ` (${f.detail})` : ""}`)
94
+ .join(", ");
95
+
96
+ if (policy === "escalate") {
97
+ return { pass: true, escalate: true, findings, detail };
98
+ }
99
+
100
+ // "refuse" (default) and `{ allow }` both fail closed on anything not allowed.
101
+ return { pass: false, escalate: false, findings, detail };
102
+ }
@@ -0,0 +1,80 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { coreKnowledgeChecks, STALE_KNOWLEDGE_BINDING_CHECK_ID } from "./knowledge-checks";
3
+ import { runPostSynthChecks } from "./post-synth";
4
+ import { DECLARABLE_MARKER, type Declarable } from "../declarable";
5
+ import type { OkfBundle, OkfConcept } from "../okf-read";
6
+
7
+ function entity(): Declarable {
8
+ return { [DECLARABLE_MARKER]: true, lexicon: "aws", entityType: "AWS::S3::Bucket" } as unknown as Declarable;
9
+ }
10
+
11
+ function concept(overrides: Partial<OkfConcept> = {}): OkfConcept {
12
+ return {
13
+ path: "decisions/example.md",
14
+ type: "decision",
15
+ binds: [],
16
+ frontmatter: {},
17
+ body: "",
18
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ function run(bundle: OkfBundle, entities: Map<string, Declarable>) {
23
+ return runPostSynthChecks(coreKnowledgeChecks(bundle), {
24
+ outputs: new Map(),
25
+ entities,
26
+ warnings: [],
27
+ errors: [],
28
+ sourceFileCount: 1,
29
+ });
30
+ }
31
+
32
+ describe("COR026: stale knowledge binding (#1865)", () => {
33
+ test("check id", () => {
34
+ expect(STALE_KNOWLEDGE_BINDING_CHECK_ID).toBe("COR026");
35
+ expect(coreKnowledgeChecks({ concepts: [] }).map((c) => c.id)).toContain("COR026");
36
+ });
37
+
38
+ test("fires once per unresolved binding, naming the concept path and the unresolved name", () => {
39
+ const bundle: OkfBundle = {
40
+ concepts: [concept({ path: "decisions/ghost.md", title: "Stale", binds: ["ghostBucket"] })],
41
+ };
42
+ const diags = run(bundle, new Map());
43
+ expect(diags).toHaveLength(1);
44
+ expect(diags[0].checkId).toBe("COR026");
45
+ expect(diags[0].severity).toBe("warning");
46
+ expect(diags[0].message).toContain("decisions/ghost.md");
47
+ expect(diags[0].message).toContain("ghostBucket");
48
+ });
49
+
50
+ test("fires one diagnostic per unresolved name when a concept binds several", () => {
51
+ const bundle: OkfBundle = {
52
+ concepts: [concept({ binds: ["ghostOne", "ghostTwo"] })],
53
+ };
54
+ const diags = run(bundle, new Map());
55
+ expect(diags).toHaveLength(2);
56
+ expect(diags.map((d) => d.message).join("\n")).toContain("ghostOne");
57
+ expect(diags.map((d) => d.message).join("\n")).toContain("ghostTwo");
58
+ });
59
+
60
+ test("does not fire when the bound name resolves to a discovered entity", () => {
61
+ const bundle: OkfBundle = { concepts: [concept({ binds: ["realBucket"] })] };
62
+ const entities = new Map<string, Declarable>([["realBucket", entity()]]);
63
+ expect(run(bundle, entities)).toHaveLength(0);
64
+ });
65
+
66
+ test("does not fire for a concept with no binds at all — orphaned knowledge is legitimate", () => {
67
+ const bundle: OkfBundle = { concepts: [concept({ binds: [] })] };
68
+ expect(run(bundle, new Map())).toHaveLength(0);
69
+ });
70
+
71
+ test("does not fire for an empty bundle (no knowledge directory)", () => {
72
+ expect(run({ concepts: [] }, new Map())).toHaveLength(0);
73
+ });
74
+
75
+ test("build and synthesis are unaffected — severity is always warning, never error", () => {
76
+ const bundle: OkfBundle = { concepts: [concept({ binds: ["ghost"] })] };
77
+ const diags = run(bundle, new Map());
78
+ expect(diags.every((d) => d.severity === "warning")).toBe(true);
79
+ });
80
+ });