@intentius/chant 0.49.0 → 0.51.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 (294) hide show
  1. package/dist/audit/catalog.d.ts +13 -3
  2. package/dist/audit/catalog.d.ts.map +1 -1
  3. package/dist/audit/core.d.ts +9 -0
  4. package/dist/audit/core.d.ts.map +1 -1
  5. package/dist/audit/discover.d.ts +6 -0
  6. package/dist/audit/discover.d.ts.map +1 -1
  7. package/dist/audit/fetch.d.ts.map +1 -1
  8. package/dist/audit/report-html.d.ts.map +1 -1
  9. package/dist/audit/report-model.d.ts +6 -0
  10. package/dist/audit/report-model.d.ts.map +1 -1
  11. package/dist/audit/report.d.ts.map +1 -1
  12. package/dist/audit/rules-doc.d.ts.map +1 -1
  13. package/dist/audit/secrets.d.ts +95 -0
  14. package/dist/audit/secrets.d.ts.map +1 -0
  15. package/dist/audit/wrangler.d.ts +33 -0
  16. package/dist/audit/wrangler.d.ts.map +1 -0
  17. package/dist/build.d.ts.map +1 -1
  18. package/dist/cli/commands/audit.d.ts +7 -0
  19. package/dist/cli/commands/audit.d.ts.map +1 -1
  20. package/dist/cli/commands/build.d.ts +23 -0
  21. package/dist/cli/commands/build.d.ts.map +1 -1
  22. package/dist/cli/handlers/build.d.ts.map +1 -1
  23. package/dist/cli/handlers/components.d.ts +31 -0
  24. package/dist/cli/handlers/components.d.ts.map +1 -1
  25. package/dist/cli/handlers/lifecycle.d.ts +11 -0
  26. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  27. package/dist/cli/handlers/op-progress.d.ts +57 -0
  28. package/dist/cli/handlers/op-progress.d.ts.map +1 -0
  29. package/dist/cli/handlers/operator.d.ts +32 -0
  30. package/dist/cli/handlers/operator.d.ts.map +1 -0
  31. package/dist/cli/handlers/run-client.d.ts +21 -1
  32. package/dist/cli/handlers/run-client.d.ts.map +1 -1
  33. package/dist/cli/handlers/run-report.d.ts.map +1 -1
  34. package/dist/cli/handlers/run.d.ts.map +1 -1
  35. package/dist/cli/handlers/scenario.d.ts +39 -0
  36. package/dist/cli/handlers/scenario.d.ts.map +1 -0
  37. package/dist/cli/handlers/search.d.ts +22 -0
  38. package/dist/cli/handlers/search.d.ts.map +1 -1
  39. package/dist/cli/main.d.ts.map +1 -1
  40. package/dist/cli/mcp/op-tools.d.ts.map +1 -1
  41. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  42. package/dist/cli/mcp/server.d.ts +35 -2
  43. package/dist/cli/mcp/server.d.ts.map +1 -1
  44. package/dist/cli/mcp/types.d.ts +29 -1
  45. package/dist/cli/mcp/types.d.ts.map +1 -1
  46. package/dist/cli/registry.d.ts +47 -3
  47. package/dist/cli/registry.d.ts.map +1 -1
  48. package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
  49. package/dist/components/capability.d.ts +17 -2
  50. package/dist/components/capability.d.ts.map +1 -1
  51. package/dist/components/cli-support.d.ts +7 -0
  52. package/dist/components/cli-support.d.ts.map +1 -1
  53. package/dist/components/component.d.ts +15 -0
  54. package/dist/components/component.d.ts.map +1 -1
  55. package/dist/components/driver.d.ts.map +1 -1
  56. package/dist/components/run-progress.d.ts +7 -5
  57. package/dist/components/run-progress.d.ts.map +1 -1
  58. package/dist/components/verbs/index.d.ts +6 -1
  59. package/dist/components/verbs/index.d.ts.map +1 -1
  60. package/dist/components/verbs/run-agent.d.ts +499 -0
  61. package/dist/components/verbs/run-agent.d.ts.map +1 -0
  62. package/dist/components/verbs/sign.d.ts +30 -0
  63. package/dist/components/verbs/sign.d.ts.map +1 -1
  64. package/dist/composite.d.ts +6 -1
  65. package/dist/composite.d.ts.map +1 -1
  66. package/dist/discovery/collect.d.ts.map +1 -1
  67. package/dist/discovery/fold-import.d.ts +15 -1
  68. package/dist/discovery/fold-import.d.ts.map +1 -1
  69. package/dist/discovery/fold-rank.d.ts +66 -0
  70. package/dist/discovery/fold-rank.d.ts.map +1 -0
  71. package/dist/discovery/index.d.ts +15 -0
  72. package/dist/discovery/index.d.ts.map +1 -1
  73. package/dist/discovery/param-deps.d.ts +17 -0
  74. package/dist/discovery/param-deps.d.ts.map +1 -0
  75. package/dist/fold/fold.d.ts +55 -2
  76. package/dist/fold/fold.d.ts.map +1 -1
  77. package/dist/fold/subset.d.ts +21 -14
  78. package/dist/fold/subset.d.ts.map +1 -1
  79. package/dist/lexicon-schema.d.ts +2 -0
  80. package/dist/lexicon-schema.d.ts.map +1 -1
  81. package/dist/lexicon.d.ts +134 -0
  82. package/dist/lexicon.d.ts.map +1 -1
  83. package/dist/lifecycle/assert-live.d.ts +77 -0
  84. package/dist/lifecycle/assert-live.d.ts.map +1 -0
  85. package/dist/lifecycle/change-set.d.ts +17 -0
  86. package/dist/lifecycle/change-set.d.ts.map +1 -1
  87. package/dist/lifecycle/converge-ledger.d.ts +90 -0
  88. package/dist/lifecycle/converge-ledger.d.ts.map +1 -0
  89. package/dist/lifecycle/deep-diff.d.ts +18 -0
  90. package/dist/lifecycle/deep-diff.d.ts.map +1 -1
  91. package/dist/lifecycle/deep-observe.d.ts +9 -1
  92. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  93. package/dist/lifecycle/disruption.d.ts +96 -0
  94. package/dist/lifecycle/disruption.d.ts.map +1 -0
  95. package/dist/lifecycle/gate-ledger.d.ts +33 -0
  96. package/dist/lifecycle/gate-ledger.d.ts.map +1 -0
  97. package/dist/lifecycle/git.d.ts +145 -21
  98. package/dist/lifecycle/git.d.ts.map +1 -1
  99. package/dist/lifecycle/index.d.ts +6 -0
  100. package/dist/lifecycle/index.d.ts.map +1 -1
  101. package/dist/lifecycle/lease.d.ts +113 -0
  102. package/dist/lifecycle/lease.d.ts.map +1 -0
  103. package/dist/lifecycle/replay.d.ts +2 -0
  104. package/dist/lifecycle/replay.d.ts.map +1 -1
  105. package/dist/lifecycle/scenario-eval.d.ts +42 -0
  106. package/dist/lifecycle/scenario-eval.d.ts.map +1 -0
  107. package/dist/lifecycle/scenario.d.ts +163 -0
  108. package/dist/lifecycle/scenario.d.ts.map +1 -0
  109. package/dist/lifecycle/symptoms.d.ts +63 -0
  110. package/dist/lifecycle/symptoms.d.ts.map +1 -0
  111. package/dist/lint/output-docs.d.ts +94 -0
  112. package/dist/lint/output-docs.d.ts.map +1 -0
  113. package/dist/lint/post-synth.d.ts +29 -0
  114. package/dist/lint/post-synth.d.ts.map +1 -1
  115. package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts +11 -0
  116. package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts.map +1 -0
  117. package/dist/lsp/lexicon-providers.d.ts +7 -0
  118. package/dist/lsp/lexicon-providers.d.ts.map +1 -1
  119. package/dist/op/activity-contract.d.ts +139 -0
  120. package/dist/op/activity-contract.d.ts.map +1 -0
  121. package/dist/op/builders.d.ts +42 -2
  122. package/dist/op/builders.d.ts.map +1 -1
  123. package/dist/op/converge-rule.d.ts +161 -0
  124. package/dist/op/converge-rule.d.ts.map +1 -0
  125. package/dist/op/generate-pipeline.d.ts +39 -0
  126. package/dist/op/generate-pipeline.d.ts.map +1 -0
  127. package/dist/op/index.d.ts +14 -0
  128. package/dist/op/index.d.ts.map +1 -1
  129. package/dist/op/local-executor.d.ts +7 -1
  130. package/dist/op/local-executor.d.ts.map +1 -1
  131. package/dist/op/op-verb-class.d.ts +42 -0
  132. package/dist/op/op-verb-class.d.ts.map +1 -0
  133. package/dist/op/operator.d.ts +128 -0
  134. package/dist/op/operator.d.ts.map +1 -0
  135. package/dist/op/step-output-ref.d.ts +187 -0
  136. package/dist/op/step-output-ref.d.ts.map +1 -0
  137. package/dist/op/types.d.ts +18 -1
  138. package/dist/op/types.d.ts.map +1 -1
  139. package/dist/provenance.d.ts +73 -3
  140. package/dist/provenance.d.ts.map +1 -1
  141. package/dist/runtime-adapter.d.ts +7 -1
  142. package/dist/runtime-adapter.d.ts.map +1 -1
  143. package/dist/serializer.d.ts +18 -0
  144. package/dist/serializer.d.ts.map +1 -1
  145. package/dist/testing.d.ts +23 -2
  146. package/dist/testing.d.ts.map +1 -1
  147. package/dist/toml.d.ts +40 -5
  148. package/dist/toml.d.ts.map +1 -1
  149. package/package.json +1 -1
  150. package/src/audit/catalog.test.ts +1 -1
  151. package/src/audit/catalog.ts +75 -3
  152. package/src/audit/core.ts +9 -0
  153. package/src/audit/discover.ts +29 -2
  154. package/src/audit/fetch.test.ts +216 -3
  155. package/src/audit/fetch.ts +270 -59
  156. package/src/audit/report-html.ts +5 -2
  157. package/src/audit/report-model.ts +9 -0
  158. package/src/audit/report.test.ts +22 -0
  159. package/src/audit/report.ts +3 -2
  160. package/src/audit/rules-doc.ts +2 -0
  161. package/src/audit/secrets.test.ts +303 -0
  162. package/src/audit/secrets.ts +406 -0
  163. package/src/audit/wrangler.test.ts +230 -0
  164. package/src/audit/wrangler.ts +290 -0
  165. package/src/build.ts +8 -3
  166. package/src/cli/command-group.ts +1 -1
  167. package/src/cli/commands/__fixtures__/schemas/sarif-2.1.0.schema.json +2882 -0
  168. package/src/cli/commands/audit.test.ts +215 -1
  169. package/src/cli/commands/audit.ts +86 -17
  170. package/src/cli/commands/build.test.ts +167 -2
  171. package/src/cli/commands/build.ts +114 -23
  172. package/src/cli/handlers/build.ts +2 -0
  173. package/src/cli/handlers/components.test.ts +199 -1
  174. package/src/cli/handlers/components.ts +160 -3
  175. package/src/cli/handlers/graph.test.ts +20 -0
  176. package/src/cli/handlers/graph.ts +10 -1
  177. package/src/cli/handlers/lifecycle.test.ts +90 -0
  178. package/src/cli/handlers/lifecycle.ts +30 -5
  179. package/src/cli/handlers/op-progress.test.ts +202 -0
  180. package/src/cli/handlers/op-progress.ts +192 -0
  181. package/src/cli/handlers/operator.test.ts +255 -0
  182. package/src/cli/handlers/operator.ts +240 -0
  183. package/src/cli/handlers/run-client.test.ts +82 -0
  184. package/src/cli/handlers/run-client.ts +85 -2
  185. package/src/cli/handlers/run-report.test.ts +62 -0
  186. package/src/cli/handlers/run-report.ts +20 -58
  187. package/src/cli/handlers/run.test.ts +144 -0
  188. package/src/cli/handlers/run.ts +40 -18
  189. package/src/cli/handlers/scenario.test.ts +456 -0
  190. package/src/cli/handlers/scenario.ts +330 -0
  191. package/src/cli/handlers/search-drift.test.ts +263 -0
  192. package/src/cli/handlers/search.ts +150 -1
  193. package/src/cli/main.test.ts +23 -0
  194. package/src/cli/main.ts +81 -1
  195. package/src/cli/mcp/op-tools.ts +17 -6
  196. package/src/cli/mcp/resource-handlers.ts +13 -5
  197. package/src/cli/mcp/server.test.ts +265 -2
  198. package/src/cli/mcp/server.ts +84 -7
  199. package/src/cli/mcp/types.ts +27 -1
  200. package/src/cli/registry.ts +47 -3
  201. package/src/codegen/docs-rule-scanning.test.ts +42 -0
  202. package/src/codegen/docs-rule-scanning.ts +25 -2
  203. package/src/components/README.md +7 -0
  204. package/src/components/capability.ts +17 -2
  205. package/src/components/cli-support.test.ts +17 -0
  206. package/src/components/cli-support.ts +13 -1
  207. package/src/components/component-schema.test.ts +32 -0
  208. package/src/components/component.schema.json +6 -0
  209. package/src/components/component.test.ts +21 -0
  210. package/src/components/component.ts +15 -0
  211. package/src/components/driver.ts +12 -4
  212. package/src/components/run-progress.ts +9 -5
  213. package/src/components/verbs/index.ts +6 -1
  214. package/src/components/verbs/run-agent.test.ts +683 -0
  215. package/src/components/verbs/run-agent.ts +786 -0
  216. package/src/components/verbs/sign.test.ts +19 -0
  217. package/src/components/verbs/sign.ts +34 -2
  218. package/src/composite.ts +31 -2
  219. package/src/discovery/collect.ts +11 -2
  220. package/src/discovery/fold-import.test.ts +54 -0
  221. package/src/discovery/fold-import.ts +178 -38
  222. package/src/discovery/fold-rank.test.ts +197 -0
  223. package/src/discovery/fold-rank.ts +346 -0
  224. package/src/discovery/index.ts +16 -1
  225. package/src/discovery/param-deps.test.ts +118 -0
  226. package/src/discovery/param-deps.ts +170 -0
  227. package/src/fold/fold.test.ts +6 -2
  228. package/src/fold/fold.ts +184 -3
  229. package/src/fold/subset.test.ts +82 -19
  230. package/src/fold/subset.ts +79 -41
  231. package/src/lexicon-schema.ts +3 -0
  232. package/src/lexicon.ts +154 -2
  233. package/src/lifecycle/assert-live.test.ts +125 -0
  234. package/src/lifecycle/assert-live.ts +154 -0
  235. package/src/lifecycle/change-set.ts +35 -3
  236. package/src/lifecycle/converge-ledger.test.ts +199 -0
  237. package/src/lifecycle/converge-ledger.ts +179 -0
  238. package/src/lifecycle/deep-diff.test.ts +79 -1
  239. package/src/lifecycle/deep-diff.ts +23 -0
  240. package/src/lifecycle/deep-observe.ts +13 -2
  241. package/src/lifecycle/disruption.test.ts +186 -0
  242. package/src/lifecycle/disruption.ts +224 -0
  243. package/src/lifecycle/gate-ledger.test.ts +103 -0
  244. package/src/lifecycle/gate-ledger.ts +140 -0
  245. package/src/lifecycle/git.test.ts +430 -0
  246. package/src/lifecycle/git.ts +446 -84
  247. package/src/lifecycle/index.ts +6 -0
  248. package/src/lifecycle/lease.test.ts +343 -0
  249. package/src/lifecycle/lease.ts +270 -0
  250. package/src/lifecycle/replay.test.ts +25 -0
  251. package/src/lifecycle/replay.ts +11 -3
  252. package/src/lifecycle/scenario-eval.test.ts +199 -0
  253. package/src/lifecycle/scenario-eval.ts +158 -0
  254. package/src/lifecycle/scenario.test.ts +195 -0
  255. package/src/lifecycle/scenario.ts +321 -0
  256. package/src/lifecycle/symptoms.test.ts +116 -0
  257. package/src/lifecycle/symptoms.ts +126 -0
  258. package/src/lint/output-docs.test.ts +220 -0
  259. package/src/lint/output-docs.ts +204 -0
  260. package/src/lint/post-synth.test.ts +97 -0
  261. package/src/lint/post-synth.ts +45 -0
  262. package/src/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.ts +26 -0
  263. package/src/lint/rules/comp/comp.test.ts +49 -1
  264. package/src/lint/rules/evl001-non-literal-expression.test.ts +8 -3
  265. package/src/lint/rules/evl001-non-literal-expression.ts +6 -6
  266. package/src/lsp/lexicon-providers.test.ts +44 -0
  267. package/src/lsp/lexicon-providers.ts +11 -1
  268. package/src/op/activity-contract.test.ts +180 -0
  269. package/src/op/activity-contract.ts +278 -0
  270. package/src/op/builders-exports.test.ts +17 -1
  271. package/src/op/builders.ts +59 -5
  272. package/src/op/converge-rule.test.ts +179 -0
  273. package/src/op/converge-rule.ts +311 -0
  274. package/src/op/generate-pipeline.test.ts +53 -0
  275. package/src/op/generate-pipeline.ts +99 -0
  276. package/src/op/index.ts +30 -0
  277. package/src/op/local-executor.test.ts +92 -0
  278. package/src/op/local-executor.ts +52 -10
  279. package/src/op/local-output.ts +1 -1
  280. package/src/op/op-verb-class.test.ts +126 -0
  281. package/src/op/op-verb-class.ts +115 -0
  282. package/src/op/operator.test.ts +346 -0
  283. package/src/op/operator.ts +213 -0
  284. package/src/op/step-output-ref.test.ts +334 -0
  285. package/src/op/step-output-ref.ts +453 -0
  286. package/src/op/types.ts +18 -1
  287. package/src/provenance.test.ts +151 -4
  288. package/src/provenance.ts +118 -4
  289. package/src/runtime-adapter.ts +31 -10
  290. package/src/serializer.ts +18 -0
  291. package/src/testing.test.ts +89 -2
  292. package/src/testing.ts +63 -3
  293. package/src/toml.test.ts +157 -384
  294. package/src/toml.ts +371 -5
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Converge ledger (#1484) — the append-only record of what a `ConvergeOp`
3
+ * tick did, one line per tick, at `<env>/converge.jsonl` on the
4
+ * `chant/lifecycle` orphan branch. Same storage shape as the release ledger
5
+ * (./release-ledger.ts): immutable, append-only JSONL, reusing
6
+ * `writeBlobToPath`/`readBlobFromPath` (./git.ts) directly rather than a new
7
+ * git-plumbing path — the exact reuse `appendReleaseRecordLine` already
8
+ * demonstrates for a second filename under the same env directory.
9
+ *
10
+ * This is also where flap-damping state lives (issue: "consecutive-fire
11
+ * counts go in the ledger ... not process memory, so damping survives worker
12
+ * restarts"): each record's `firedRuleIds` names every rule whose predicate
13
+ * matched this tick, independent of what action was actually taken (run,
14
+ * report, or skipped for budget/flap reasons) — {@link consecutiveRuleFires}
15
+ * walks the ledger backward from the newest record and counts how many in a
16
+ * row include a given rule id, stopping at the first tick where it didn't
17
+ * fire (the symptom cleared).
18
+ */
19
+ import { sortedJsonReplacer } from "../utils";
20
+ import { readBlobFromPath, readPathSha, readBlobBySha, writeBlobToPath, RefCASConflictError } from "./git";
21
+
22
+ const FILENAME = "converge.jsonl";
23
+
24
+ /**
25
+ * Read-modify-append retry budget for {@link appendConvergeRecord} (#1485).
26
+ * `writeBlobToPath`'s ref write is now CAS-guarded (./git.ts) — a conflict
27
+ * (another writer, e.g. a second operator ticking a different env on the
28
+ * same orphan branch, updated the branch tip between our read and our
29
+ * write) throws `RefCASConflictError` instead of silently clobbering. This
30
+ * many attempts, re-reading the ledger fresh each time, absorbs that race
31
+ * rather than surfacing it as a tick failure — the whole point of the CAS
32
+ * guard is correctness, not a new way for a tick to fail.
33
+ */
34
+ const APPEND_RETRY_ATTEMPTS = 5;
35
+
36
+ /** One rule's outcome within a tick. */
37
+ export interface ConvergeRuleOutcome {
38
+ ruleId: string;
39
+ /**
40
+ * What actually happened for this fired rule this tick. `"gated"` (#1485)
41
+ * is gate-as-fact: the dispatched op's own run hit a gate it can't clear
42
+ * on the local executor, and the tick records that as a terminal, durable
43
+ * fact — `gateName` names the gate — rather than treating it as a
44
+ * dispatch failure. Resolution is out-of-band (`chant approve <op>
45
+ * <gate>`, ./gate-ledger.ts, or a merged PR); the tick itself never
46
+ * blocks waiting for it.
47
+ */
48
+ action: "ran" | "reported" | "skipped-budget" | "skipped-flap" | "gated";
49
+ /** The dispatched Op name, for `action: "ran"` or `"gated"`. */
50
+ op?: string;
51
+ /** The gate's signal name, for `action: "gated"`. */
52
+ gateName?: string;
53
+ /** The report reason, for `action: "reported"` (including a flap-damped rule's forced report) — and the human-readable explanation for `action: "gated"`. */
54
+ reason?: string;
55
+ }
56
+
57
+ /** One immutable converge-tick record. */
58
+ export interface ConvergeTickRecord {
59
+ /** Schema version, so an incompatible future shape is detected before being misread. */
60
+ version: 1;
61
+ /** The ConvergeOp's name (`OpConfig.name`). */
62
+ op: string;
63
+ env: string;
64
+ /** ISO-8601 timestamp, caller-supplied (same convention as `ReleaseRecord.timestamp` — library code never calls `Date.now()` internally). */
65
+ timestamp: string;
66
+ /** Every rule id whose predicate matched this tick, regardless of what action followed — the flap-damping input. */
67
+ firedRuleIds: string[];
68
+ /** Per-rule outcome, for every fired rule. */
69
+ outcomes: ConvergeRuleOutcome[];
70
+ /** Aggregate counts backing the tick's one log line. */
71
+ summary: {
72
+ drifted: number;
73
+ remediated: number;
74
+ reported: number;
75
+ skippedBudget: number;
76
+ skippedFlap: number;
77
+ unobserved: number;
78
+ adopted: number;
79
+ /** Rules whose dispatch hit a gate this tick (#1485) — a terminal, non-blocking fact; see `ConvergeRuleOutcome.action`'s doc. @default 0, so an older record without this field still reads as zero, not undefined. */
80
+ gated?: number;
81
+ };
82
+ /** The one human-readable log line this tick produced (issue: "one log line and one ledger record per tick"). */
83
+ log: string;
84
+ }
85
+
86
+ export type ConvergeTickRecordInput = Omit<ConvergeTickRecord, "version">;
87
+
88
+ /**
89
+ * Append one immutable tick record. Does not push to the remote — call
90
+ * `pushLifecycle` (./git.ts) afterward, same two-step shape every other
91
+ * ledger write here uses.
92
+ *
93
+ * Retries the whole read-modify-write cycle (#1485) on `RefCASConflictError`
94
+ * — `writeBlobToPath`'s ref write is CAS-guarded, so a concurrent writer to
95
+ * a *different* env's file on the same orphan branch (two operators ticking
96
+ * two environments at once is the ordinary case, not an edge case) can lose
97
+ * the race and needs to re-read the branch tip and retry, not fail the
98
+ * tick. Each retry re-reads `existing` fresh, so it always appends onto
99
+ * whatever the other writer just committed rather than reintroducing a stale
100
+ * read. Exhausting the budget re-throws the conflict — a real, sustained
101
+ * pile-up of writers is a signal worth surfacing, not silently swallowing.
102
+ *
103
+ * The baseline read must be `readPathSha` + `readBlobBySha` rather than
104
+ * `readBlobFromPath`, so the exact sha `existing` came from can be passed as
105
+ * `expectPriorPathSha`. See `writeBlobToPath` (./git.ts) for the race that
106
+ * closes.
107
+ */
108
+ export async function appendConvergeRecord(
109
+ input: ConvergeTickRecordInput,
110
+ opts?: { cwd?: string },
111
+ ): Promise<{ commit: string; record: ConvergeTickRecord }> {
112
+ const record: ConvergeTickRecord = { version: 1, ...input };
113
+ const json = JSON.stringify(record, sortedJsonReplacer);
114
+
115
+ let lastErr: unknown;
116
+ for (let attempt = 1; attempt <= APPEND_RETRY_ATTEMPTS; attempt++) {
117
+ try {
118
+ const priorSha = await readPathSha(record.env, FILENAME, opts);
119
+ const existing = priorSha ? await readBlobBySha(priorSha, opts) : null;
120
+ const content = existing ? `${existing.replace(/\n$/, "")}\n${json}` : json;
121
+ const commit = await writeBlobToPath(record.env, FILENAME, content, "Converge tick record", {
122
+ ...opts,
123
+ expectPriorPathSha: priorSha,
124
+ });
125
+ return { commit, record };
126
+ } catch (err) {
127
+ if (!(err instanceof RefCASConflictError)) throw err;
128
+ lastErr = err;
129
+ }
130
+ }
131
+ throw lastErr;
132
+ }
133
+
134
+ /** Read every tick record for `environment`, oldest first. Malformed lines are skipped, not thrown on — a corrupted or hand-edited ledger degrades gracefully, the same stance `readReleaseLedger` takes. */
135
+ export async function readConvergeLedger(
136
+ environment: string,
137
+ opts?: { cwd?: string },
138
+ ): Promise<{ records: ConvergeTickRecord[]; malformed: number }> {
139
+ const content = await readBlobFromPath(environment, FILENAME, opts);
140
+ if (!content) return { records: [], malformed: 0 };
141
+
142
+ const lines = content.split("\n").map((l) => l.trim()).filter(Boolean);
143
+ const records: ConvergeTickRecord[] = [];
144
+ let malformed = 0;
145
+ for (const line of lines) {
146
+ try {
147
+ const parsed = JSON.parse(line) as Partial<ConvergeTickRecord>;
148
+ if (
149
+ parsed.version !== 1 ||
150
+ typeof parsed.op !== "string" ||
151
+ typeof parsed.env !== "string" ||
152
+ typeof parsed.timestamp !== "string" ||
153
+ !Array.isArray(parsed.firedRuleIds)
154
+ ) {
155
+ malformed++;
156
+ continue;
157
+ }
158
+ records.push(parsed as ConvergeTickRecord);
159
+ } catch {
160
+ malformed++;
161
+ }
162
+ }
163
+ return { records, malformed };
164
+ }
165
+
166
+ /**
167
+ * How many consecutive most-recent ticks (newest first) fired `ruleId`,
168
+ * stopping at the first tick where it did not — the count a rule's
169
+ * `flapThreshold` is compared against. `0` when the newest tick didn't fire
170
+ * it (including an empty ledger).
171
+ */
172
+ export function consecutiveRuleFires(records: ConvergeTickRecord[], ruleId: string): number {
173
+ let count = 0;
174
+ for (let i = records.length - 1; i >= 0; i--) {
175
+ if (!records[i].firedRuleIds.includes(ruleId)) break;
176
+ count++;
177
+ }
178
+ return count;
179
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect } from "vitest";
2
- import { countPropertyDrift, diffDeep } from "./deep-diff";
2
+ import { countPropertyDrift, diffDeep, type DeclaredDeepEntity } from "./deep-diff";
3
3
  import { UNRESOLVED, type NormalizedDeepObservation } from "../deep-observation";
4
4
  import type { BaselineLexicon } from "./observation-baseline";
5
5
 
@@ -202,3 +202,81 @@ describe("diffDeep — owning field manager (#1189)", () => {
202
202
  expect(change).not.toHaveProperty("owner");
203
203
  });
204
204
  });
205
+
206
+ // #1443 — the declared-side counterpart of `owner`. Both sides of one
207
+ // comparison get precise attribution, which is what makes the report actionable.
208
+ describe("diffDeep — declared-side origin (#1443)", () => {
209
+ // Annotated, not `as const`: the annotation is what keeps `kind` narrowed to
210
+ // its literal, and it types the fixture as the production shape rather than
211
+ // whatever the literal happens to infer to.
212
+ const declared: Record<string, DeclaredDeepEntity> = {
213
+ web: {
214
+ type: "K8s::Apps::Deployment",
215
+ properties: { spec: { replicas: 2, template: { spec: { containers: [{ name: "app", image: "a:1" }] } } } },
216
+ pathOrigins: {
217
+ "": { kind: "composite", composite: "WebService", instance: "web" },
218
+ "spec.replicas": { kind: "build-param", params: ["tier"] },
219
+ },
220
+ },
221
+ };
222
+
223
+ test("reports the origin alongside the live owner", () => {
224
+ const result = diffDeep({
225
+ declared,
226
+ live: live({
227
+ web: {
228
+ type: "K8s::Apps::Deployment",
229
+ properties: { spec: { replicas: 5, template: { spec: { containers: [{ name: "app", image: "a:1" }] } } } },
230
+ fieldOwners: { "spec.replicas": "hpa-controller" },
231
+ },
232
+ }),
233
+ });
234
+ expect(result.drifted[0].changes[0]).toMatchObject({
235
+ path: "spec.replicas",
236
+ owner: "hpa-controller",
237
+ origin: { kind: "build-param", params: ["tier"] },
238
+ });
239
+ });
240
+
241
+ test("a path with no origin of its own inherits the nearest recorded ancestor", () => {
242
+ const result = diffDeep({
243
+ declared,
244
+ live: live({
245
+ web: {
246
+ type: "K8s::Apps::Deployment",
247
+ properties: { spec: { replicas: 2, template: { spec: { containers: [{ name: "app", image: "a:2" }] } } } },
248
+ },
249
+ }),
250
+ });
251
+ const change = result.drifted[0].changes.find((c) => c.path.includes("image"))!;
252
+ expect(change.origin).toEqual({ kind: "composite", composite: "WebService", instance: "web" });
253
+ });
254
+
255
+ test("is absent on an undeclared path — nothing in source produced it", () => {
256
+ const result = diffDeep({
257
+ declared,
258
+ live: live({
259
+ web: {
260
+ type: "K8s::Apps::Deployment",
261
+ properties: {
262
+ spec: { replicas: 2, template: { spec: { containers: [{ name: "app", image: "a:1" }] } } },
263
+ status: { observed: 1 },
264
+ },
265
+ },
266
+ }),
267
+ });
268
+ const change = result.drifted[0].changes.find((c) => c.path === "status.observed")!;
269
+ expect(change.kind).toBe("undeclared");
270
+ expect(change).not.toHaveProperty("origin");
271
+ });
272
+
273
+ test("is absent when the build recorded no path origins", () => {
274
+ // The run path, and a sandboxed child: absent means "could not record",
275
+ // not "nothing governs this field", so the key must not appear at all.
276
+ const result = diffDeep({
277
+ declared: { web: { type: "K8s::Apps::Deployment", properties: { spec: { replicas: 2 } } } },
278
+ live: live({ web: { type: "K8s::Apps::Deployment", properties: { spec: { replicas: 5 } } } }),
279
+ });
280
+ expect(result.drifted[0].changes[0]).not.toHaveProperty("origin");
281
+ });
282
+ });
@@ -29,6 +29,7 @@ import {
29
29
  type DeepNormalizationHooks,
30
30
  type NormalizedDeepObservation,
31
31
  } from "../deep-observation";
32
+ import { originOfPath, type PathOrigin } from "../provenance";
32
33
  import type { UnobservedResource } from "./live-diff";
33
34
  import { acceptedDeviation, type BaselineLexicon } from "./observation-baseline";
34
35
 
@@ -70,6 +71,18 @@ export interface PropertyDrift {
70
71
  * with no per-field ownership, which is every substrate but k8s.
71
72
  */
72
73
  owner?: string;
74
+ /**
75
+ * What produced this path on the DECLARED side (#1443) — the counterpart of
76
+ * {@link owner}, and the reason the two are reported together: "owned live by
77
+ * `hpa-controller`, governed in source by the `tier` parameter" says where
78
+ * each half of a disagreement has to be fixed, which neither half says alone.
79
+ *
80
+ * Resolved by longest prefix from the entity's recorded path origins, so a
81
+ * field inside a keyed list element inherits the origin recorded for the
82
+ * list. Absent when the build recorded none — the run path, and a sandboxed
83
+ * child, have no expression to attribute (see `EntityProvenance.paths`).
84
+ */
85
+ origin?: PathOrigin;
73
86
  }
74
87
 
75
88
  /** Property-level drift for one declared entity. */
@@ -100,6 +113,11 @@ export interface DeepDiffResult {
100
113
  export interface DeclaredDeepEntity {
101
114
  type: string;
102
115
  properties: Record<string, unknown>;
116
+ /**
117
+ * The entity's recorded path origins (#1443), as `EntityProvenance.paths`.
118
+ * Omit for a build that recorded none.
119
+ */
120
+ pathOrigins?: Record<string, PathOrigin>;
103
121
  }
104
122
 
105
123
  export interface DiffDeepInput {
@@ -188,12 +206,17 @@ export function diffDeep(input: DiffDeepInput): DeepDiffResult {
188
206
  // meaningful for a path that exists live — an `absent` drift has no live
189
207
  // field for anyone to own.
190
208
  const owner = hasLive ? liveEntity.fieldOwners?.[path] : undefined;
209
+ // The declared-side counterpart (#1443). Only meaningful for a path
210
+ // source actually declares — an `undeclared` drift has no authored
211
+ // expression for anything to have produced.
212
+ const origin = hasDeclared ? originOfPath(declaredEntity.pathOrigins, path) : undefined;
191
213
  const drift: PropertyDrift = {
192
214
  path,
193
215
  kind,
194
216
  ...(hasDeclared ? { declared: declaredValue } : {}),
195
217
  ...(hasLive ? { live: liveValue } : {}),
196
218
  ...(owner ? { owner } : {}),
219
+ ...(origin ? { origin } : {}),
197
220
  };
198
221
 
199
222
  const acceptedEntry = acceptedDeviation(baseline, name, path);
@@ -27,11 +27,21 @@ import {
27
27
  type NormalizedDeepObservation,
28
28
  } from "../deep-observation";
29
29
  import { unobservedAll, type UnobservedEntity } from "../observation";
30
+ import type { PathOrigin } from "../provenance";
30
31
  import { diffDeep, type DeclaredDeepEntity, type DeepDiffResult } from "./deep-diff";
31
32
  import type { BaselineLexicon } from "./observation-baseline";
32
33
 
33
- /** Declared entities for one lexicon, in the shape the observe paths pass around. */
34
- export type DeclaredEntities = Map<string, { entityType: string; props: Record<string, unknown> }>;
34
+ /**
35
+ * Declared entities for one lexicon, in the shape the observe paths pass around.
36
+ *
37
+ * `pathOrigins` (#1443) rides alongside `props` rather than being read off the
38
+ * entity, because callers rebuild this map as plain objects and the symbol-keyed
39
+ * provenance channel does not survive that copy.
40
+ */
41
+ export type DeclaredEntities = Map<
42
+ string,
43
+ { entityType: string; props: Record<string, unknown>; pathOrigins?: Record<string, PathOrigin> }
44
+ >;
35
45
 
36
46
  export interface DeepObserveOptions {
37
47
  environment: string;
@@ -138,6 +148,7 @@ export function diffDeepObservation(
138
148
  hooks,
139
149
  counterpartPaths: livePaths,
140
150
  }),
151
+ ...(entity.pathOrigins ? { pathOrigins: entity.pathOrigins } : {}),
141
152
  };
142
153
  if (liveEntity) {
143
154
  normalizedLive[name] = {
@@ -0,0 +1,186 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import {
3
+ annotateDisruption,
4
+ disruptionNotices,
5
+ summarizeDisruption,
6
+ worstDisruption,
7
+ type DisruptionClassifier,
8
+ } from "./disruption";
9
+ import { renderChangeSet, type ChangeSet, type ChangeSetEntry } from "./change-set";
10
+
11
+ function entry(overrides: Partial<ChangeSetEntry> = {}): ChangeSetEntry {
12
+ return {
13
+ name: "db",
14
+ type: "AWS::RDS::DBInstance",
15
+ lexicon: "aws",
16
+ action: "update",
17
+ evidence: { declared: true, inSnapshot: true, live: true, observed: true },
18
+ deltas: [{ path: "attributes.Engine", oldValue: "postgres", newValue: "mysql" }],
19
+ ownership: "unknown",
20
+ ...overrides,
21
+ };
22
+ }
23
+
24
+ function set(entries: ChangeSetEntry[]): ChangeSet {
25
+ return { env: "prod", entries };
26
+ }
27
+
28
+ describe("annotateDisruption (#1665)", () => {
29
+ test("no classifier degrades every update to unknown, never in-place", async () => {
30
+ const out = await annotateDisruption(set([entry()]), "prod", undefined);
31
+ expect(out.entries[0].disruption).toBe("unknown");
32
+ expect(out.entries[0].disruptionDetail).toContain("aws lexicon does not classify disruption");
33
+ });
34
+
35
+ test("a lexicon's verdict rides the entry", async () => {
36
+ const classify: DisruptionClassifier = () => ({
37
+ db: { disruption: "replace", because: ["attributes.Engine"], detail: "Engine is create-only" },
38
+ });
39
+ const out = await annotateDisruption(set([entry()]), "prod", classify);
40
+ expect(out.entries[0]).toMatchObject({
41
+ disruption: "replace",
42
+ disruptionBecause: ["attributes.Engine"],
43
+ disruptionDetail: "Engine is create-only",
44
+ });
45
+ });
46
+
47
+ test("a name the classifier said nothing about is unknown", async () => {
48
+ const classify: DisruptionClassifier = () => ({});
49
+ const out = await annotateDisruption(set([entry()]), "prod", classify);
50
+ expect(out.entries[0].disruption).toBe("unknown");
51
+ expect(out.entries[0].disruptionDetail).toContain("returned no verdict");
52
+ });
53
+
54
+ // The guard that makes `in-place` trustworthy: a lexicon cannot smuggle a
55
+ // level in that core does not recognise, and a bogus one is never treated as
56
+ // the safe end of the scale.
57
+ test("a level outside the vocabulary is rejected into unknown", async () => {
58
+ const classify = (() => ({
59
+ db: { disruption: "totally-fine", detail: "trust me" },
60
+ })) as unknown as DisruptionClassifier;
61
+ const out = await annotateDisruption(set([entry()]), "prod", classify);
62
+ expect(out.entries[0].disruption).toBe("unknown");
63
+ expect(out.entries[0].disruptionDetail).toContain("totally-fine");
64
+ });
65
+
66
+ test("a classifier that throws leaves unknown, and the plan survives", async () => {
67
+ const classify: DisruptionClassifier = () => {
68
+ throw new Error("registry missing");
69
+ };
70
+ const out = await annotateDisruption(set([entry()]), "prod", classify);
71
+ expect(out.entries[0].disruption).toBe("unknown");
72
+ expect(out.entries[0].disruptionDetail).toContain("registry missing");
73
+ });
74
+
75
+ test("an async classifier is awaited", async () => {
76
+ const classify: DisruptionClassifier = async () => ({
77
+ db: { disruption: "in-place", detail: "no create-only property changed" },
78
+ });
79
+ const out = await annotateDisruption(set([entry()]), "prod", classify);
80
+ expect(out.entries[0].disruption).toBe("in-place");
81
+ });
82
+
83
+ test("only update entries are classified", async () => {
84
+ const classify: DisruptionClassifier = ({ changes }) => {
85
+ expect(changes.map((c) => c.name)).toEqual(["db"]);
86
+ return { db: { disruption: "destroy" } };
87
+ };
88
+ const out = await annotateDisruption(
89
+ set([
90
+ entry(),
91
+ entry({ name: "bucket", action: "create", deltas: undefined }),
92
+ entry({ name: "queue", action: "delete", deltas: undefined }),
93
+ ]),
94
+ "prod",
95
+ classify,
96
+ );
97
+ const byName = Object.fromEntries(out.entries.map((e) => [e.name, e]));
98
+ expect(byName.db.disruption).toBe("destroy");
99
+ expect(byName.bucket.disruption).toBeUndefined();
100
+ expect(byName.queue.disruption).toBeUndefined();
101
+ });
102
+
103
+ test("a set with no updates is returned untouched, and no classifier is called", async () => {
104
+ let called = false;
105
+ const cs = set([entry({ action: "noop", deltas: undefined })]);
106
+ const out = await annotateDisruption(cs, "prod", () => {
107
+ called = true;
108
+ return {};
109
+ });
110
+ expect(out).toBe(cs);
111
+ expect(called).toBe(false);
112
+ });
113
+
114
+ test("the input change set is not mutated", async () => {
115
+ const cs = set([entry()]);
116
+ await annotateDisruption(cs, "prod", () => ({ db: { disruption: "replace" } }));
117
+ expect(cs.entries[0].disruption).toBeUndefined();
118
+ });
119
+ });
120
+
121
+ describe("summaries and notices", () => {
122
+ const classified = set([
123
+ entry({ name: "db", disruption: "destroy" }),
124
+ entry({ name: "sg", disruption: "replace" }),
125
+ entry({ name: "tags", disruption: "in-place" }),
126
+ entry({ name: "mystery", disruption: "unknown" }),
127
+ entry({ name: "bucket", action: "noop", disruption: undefined, deltas: undefined }),
128
+ ]);
129
+
130
+ test("summarizeDisruption counts update entries only", () => {
131
+ expect(summarizeDisruption(classified)).toEqual({
132
+ "in-place": 1,
133
+ rolling: 0,
134
+ replace: 1,
135
+ destroy: 1,
136
+ unknown: 1,
137
+ });
138
+ });
139
+
140
+ test("worstDisruption ranks unknown above every confident verdict", () => {
141
+ expect(worstDisruption(classified)).toBe("unknown");
142
+ expect(worstDisruption(set([entry({ disruption: "in-place" })]))).toBe("in-place");
143
+ expect(worstDisruption(set([entry({ action: "noop", disruption: undefined })]))).toBeUndefined();
144
+ });
145
+
146
+ test("notices name the replacing and the unclassified rows", () => {
147
+ const notices = disruptionNotices(classified);
148
+ expect(notices[0]).toContain("2 update(s) replace the resource");
149
+ expect(notices[0]).toContain("1 of them by deleting it first");
150
+ expect(notices[1]).toContain("1 update(s) could not be classified");
151
+ });
152
+
153
+ test("a clean plan produces no notices", () => {
154
+ expect(disruptionNotices(set([entry({ disruption: "in-place" })]))).toEqual([]);
155
+ });
156
+ });
157
+
158
+ describe("renderChangeSet with disruption", () => {
159
+ test("the header, the row, and the forcing delta all say it", () => {
160
+ const out = renderChangeSet(
161
+ set([
162
+ entry({
163
+ disruption: "replace",
164
+ disruptionBecause: ["attributes.Engine"],
165
+ disruptionDetail: "Engine is create-only",
166
+ deltas: [
167
+ { path: "attributes.Engine", oldValue: "postgres", newValue: "mysql" },
168
+ { path: "attributes.AllocatedStorage", oldValue: 20, newValue: 40 },
169
+ ],
170
+ }),
171
+ ]),
172
+ );
173
+ expect(out).toContain("Disruption: 1 replace");
174
+ expect(out).toContain("UPDATE (disruption from the lexicon that owns the spec");
175
+ expect(out).toContain("db (AWS::RDS::DBInstance) — replace: Engine is create-only");
176
+ expect(out).toContain("! attributes.Engine:");
177
+ expect(out).toContain(" attributes.AllocatedStorage:");
178
+ expect(out).not.toContain("! attributes.AllocatedStorage");
179
+ });
180
+
181
+ test("an unclassified plan renders the plain UPDATE header", () => {
182
+ const out = renderChangeSet(set([entry({ disruption: undefined })]));
183
+ expect(out).toContain("\nUPDATE:");
184
+ expect(out).not.toContain("Disruption:");
185
+ });
186
+ });