@intentius/chant-lexicon-k8s 0.57.0 → 0.59.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 (89) hide show
  1. package/dist/api/fake-cluster.d.ts +7 -6
  2. package/dist/api/fake-cluster.d.ts.map +1 -1
  3. package/dist/api/sweep-types.d.ts +1 -2
  4. package/dist/api/sweep-types.d.ts.map +1 -1
  5. package/dist/codegen/docs.d.ts.map +1 -1
  6. package/dist/composites/cron-schedule.d.ts +26 -0
  7. package/dist/composites/cron-schedule.d.ts.map +1 -0
  8. package/dist/composites/cron-workload.d.ts +1 -1
  9. package/dist/composites/cron-workload.d.ts.map +1 -1
  10. package/dist/composites/operator-stack.d.ts +20 -19
  11. package/dist/composites/operator-stack.d.ts.map +1 -1
  12. package/dist/config-schema.d.ts +3 -0
  13. package/dist/config-schema.d.ts.map +1 -1
  14. package/dist/config.d.ts +26 -4
  15. package/dist/config.d.ts.map +1 -1
  16. package/dist/deep-observe-hooks.d.ts +4 -4
  17. package/dist/deep-observe.d.ts +23 -16
  18. package/dist/deep-observe.d.ts.map +1 -1
  19. package/dist/describe-resources.d.ts.map +1 -1
  20. package/dist/effect-receipt-row.d.ts +164 -0
  21. package/dist/effect-receipt-row.d.ts.map +1 -0
  22. package/dist/export-resources.d.ts +1 -1
  23. package/dist/index.d.ts +2 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/integrity.json +4 -4
  26. package/dist/lint/audit-catalog.d.ts.map +1 -1
  27. package/dist/lint/audit-lineage.d.ts +10 -0
  28. package/dist/lint/audit-lineage.d.ts.map +1 -0
  29. package/dist/lint/post-synth/wk8505.d.ts +3 -1
  30. package/dist/lint/post-synth/wk8505.d.ts.map +1 -1
  31. package/dist/manifest.json +1 -1
  32. package/dist/okf/index.md +1 -1
  33. package/dist/okf/rules/WK8505.md +2 -2
  34. package/dist/okf/types/Kustomization.md +1 -1
  35. package/dist/op/activities/argo.d.ts +4 -4
  36. package/dist/op/activities/argo.d.ts.map +1 -1
  37. package/dist/op/activities/index.d.ts +15 -7
  38. package/dist/op/activities/index.d.ts.map +1 -1
  39. package/dist/op/activities/kubectl.d.ts +7 -7
  40. package/dist/op/activities/kubectl.d.ts.map +1 -1
  41. package/dist/op/activities/wait-for-ready.d.ts +6 -5
  42. package/dist/op/activities/wait-for-ready.d.ts.map +1 -1
  43. package/dist/op/builders.d.ts +13 -13
  44. package/dist/plugin.d.ts.map +1 -1
  45. package/dist/receipt-store.d.ts +113 -0
  46. package/dist/receipt-store.d.ts.map +1 -0
  47. package/dist/rules/wk8505.ts +4 -2
  48. package/dist/serializer.d.ts.map +1 -1
  49. package/dist/skills/chant-k8s-argo.md +27 -13
  50. package/dist/subscribe-changes.d.ts +84 -0
  51. package/dist/subscribe-changes.d.ts.map +1 -0
  52. package/package.json +3 -3
  53. package/src/api/fake-cluster.ts +17 -4
  54. package/src/api/sweep-types.ts +1 -2
  55. package/src/codegen/docs.ts +7 -0
  56. package/src/composites/composites.test.ts +72 -3
  57. package/src/composites/cron-schedule.ts +47 -0
  58. package/src/composites/cron-workload.ts +4 -1
  59. package/src/composites/operator-stack.ts +25 -24
  60. package/src/config-schema.ts +5 -0
  61. package/src/config.ts +27 -4
  62. package/src/crd/crd-sources.ts +4 -4
  63. package/src/deep-observe-hooks.ts +4 -4
  64. package/src/deep-observe.test.ts +219 -46
  65. package/src/deep-observe.ts +54 -28
  66. package/src/describe-resources.ts +31 -7
  67. package/src/effect-receipt-row.test.ts +285 -0
  68. package/src/effect-receipt-row.ts +268 -0
  69. package/src/export-resources.ts +1 -1
  70. package/src/index.ts +22 -0
  71. package/src/lint/audit-catalog.ts +6 -2
  72. package/src/lint/audit-lineage.ts +127 -0
  73. package/src/lint/post-synth/wk8505.ts +4 -2
  74. package/src/op/activities/argo.test.ts +7 -8
  75. package/src/op/activities/argo.ts +4 -4
  76. package/src/op/activities/index.ts +23 -7
  77. package/src/op/activities/kubectl.test.ts +43 -4
  78. package/src/op/activities/kubectl.ts +67 -67
  79. package/src/op/activities/wait-for-ready.test.ts +2 -2
  80. package/src/op/activities/wait-for-ready.ts +7 -7
  81. package/src/op/builders.ts +13 -13
  82. package/src/plugin.ts +16 -1
  83. package/src/receipt-store.test.ts +380 -0
  84. package/src/receipt-store.ts +290 -0
  85. package/src/serializer.test.ts +26 -0
  86. package/src/serializer.ts +92 -1
  87. package/src/skills/chant-k8s-argo.md +27 -13
  88. package/src/subscribe-changes.test.ts +368 -0
  89. package/src/subscribe-changes.ts +210 -0
@@ -0,0 +1,290 @@
1
+ /**
2
+ * The k8s `ReceiptStore` (#2074, epic #1703): core's injectable receipt seam
3
+ * (#1834, `@intentius/chant/op/receipt-store`) implemented over a core
4
+ * `ConfigMap`, at the address ./effect-receipt-row.ts derives from the
5
+ * ownership marker fields, plus the plan-side live read of the same rows.
6
+ *
7
+ * The transport is the lexicon's own typed client (./api/connect.ts), so a
8
+ * receipt read and write take the same environment-to-cluster binding
9
+ * (#1100/#1155), the same credential policy and the same field manager as
10
+ * every other k8s mutation. No `kubectl` binary is involved.
11
+ *
12
+ * Write discipline (epic decision 3): `write` exists for the `effect()` step
13
+ * alone, because the step's read-compare-run-write is the only path that reaches it,
14
+ * on success, last. It is a server-side apply as `chant:<stack>`, stamping the
15
+ * ownership marker labels and {@link RECEIPT_LABEL_KEY}, so a later write
16
+ * updates the value in place and the owned-only prune retains rather than
17
+ * deletes it (./op/activities/kubectl.ts).
18
+ *
19
+ * Identity: the ConfigMap name needs `<stack>` and `<env>`, which the activity
20
+ * args deliberately do not carry (the `EffectReceiptRef` is
21
+ * identity-of-the-effect, not identity-of-the-deployment). The store resolves
22
+ * them once, lazily, at first use: an explicit option, else `CHANT_ENV` (what
23
+ * `chant run --env` sets) and the project's `ownership` block, the same fields
24
+ * that stamp markers (epic decision 4). Nothing resolving is an error, never a
25
+ * guessed segment.
26
+ *
27
+ * Not exported from the package entry point. Like ./secret-store.ts and
28
+ * ./teardown.ts, this module names the API client, which must stay off the
29
+ * build path (chant #1074, examples/k8s-client-boundary.test.ts). Consumers
30
+ * reach it by subpath: `@intentius/chant-lexicon-k8s/receipt-store`.
31
+ */
32
+
33
+ import { loadChantConfigUpward, resolveOwnershipStack } from "@intentius/chant/config";
34
+ import {
35
+ LABEL_OWNERSHIP_KEYS,
36
+ ownershipEntries,
37
+ classifyOwnership,
38
+ readOwnership,
39
+ } from "@intentius/chant/ownership";
40
+ import type { EffectReceiptRef, ReceiptStore } from "@intentius/chant/op/receipt-store";
41
+ import type { ResourceMetadata, UnobservedEntity } from "@intentius/chant/lexicon";
42
+ import type { DeepResourceObservation } from "@intentius/chant/deep-observation";
43
+ import type { K8sClient, K8sObject } from "@intentius/chant-k8s-client";
44
+ import { defaultK8sConnector, type K8sConnector } from "./api/connect";
45
+ import { classifyApiFailure } from "./api/classify";
46
+ import {
47
+ K8S_EFFECT_RECEIPT_ENTITY_TYPE,
48
+ RECEIPT_CONFIGMAP_REF,
49
+ RECEIPT_DATA_KEY,
50
+ RECEIPT_LABEL_KEY,
51
+ parseReceiptComment,
52
+ receiptConfigMapRef,
53
+ receiptNamespaceFrom,
54
+ type RenderedReceiptRow,
55
+ } from "./effect-receipt-row";
56
+
57
+ /** Options for {@link k8sReceiptStore}. All optional: the default store reads
58
+ * its identity and its namespace from the project, and its cluster from the
59
+ * environment binding. */
60
+ export interface K8sReceiptStoreOptions {
61
+ /** The name's `<stack>` segment. Omitted, the project's `ownership.stack`
62
+ * (chant.config.ts, found upward from `cwd`) answers. */
63
+ stack?: string;
64
+ /** The name's `<env>` segment, explicit by decision 4. Omitted, `CHANT_ENV`
65
+ * (set by `chant run --env`) answers, then a literal `ownership.env`. */
66
+ environment?: string;
67
+ /** Namespace the receipts live in. Omitted, `k8s.receipts.namespace`
68
+ * answers, then `default`. */
69
+ namespace?: string;
70
+ /** Where to look for chant.config.ts. Defaults to the working directory. */
71
+ cwd?: string;
72
+ /** Explicit kubectl context, for a caller that already resolved one. */
73
+ context?: string;
74
+ /** The connector to build a client with. Test seam. */
75
+ connect?: K8sConnector;
76
+ /** Environment record the identity fallback reads. Defaults to `process.env`. */
77
+ env?: Record<string, string | undefined>;
78
+ }
79
+
80
+ /** The resolved name identity. */
81
+ interface ReceiptIdentity {
82
+ stack: string;
83
+ env: string;
84
+ namespace: string;
85
+ }
86
+
87
+ async function resolveIdentity(options: K8sReceiptStoreOptions): Promise<ReceiptIdentity> {
88
+ const processEnv = options.env ?? process.env;
89
+ let stack = options.stack;
90
+ let env = options.environment ?? processEnv.CHANT_ENV;
91
+ let namespace = options.namespace;
92
+ if (!stack || !env || !namespace) {
93
+ let config;
94
+ try {
95
+ config = (await loadChantConfigUpward(options.cwd ?? process.cwd())).config;
96
+ } catch {
97
+ config = undefined;
98
+ }
99
+ if (config) {
100
+ stack = stack ?? resolveOwnershipStack(config);
101
+ // Only a literal env can answer here: a `{ param }` reference resolves
102
+ // per build, and an op run has no build parameters, and `--env` does.
103
+ const configEnv = config.ownership?.env;
104
+ env = env ?? (typeof configEnv === "string" ? configEnv : undefined);
105
+ namespace = namespace ?? receiptNamespaceFrom(config as unknown as Record<string, unknown>);
106
+ }
107
+ }
108
+ if (!stack) {
109
+ throw new Error(
110
+ "k8s receipt store: no stack identity. The receipt ConfigMap is named " +
111
+ "chant-receipt.<stack>.<env>.<effect>, derived from the same ownership fields that stamp " +
112
+ "markers (chant #1703, decision 4). Set ownership: { stack } in chant.config.ts.",
113
+ );
114
+ }
115
+ if (!env) {
116
+ throw new Error(
117
+ "k8s receipt store: no environment resolved. The receipt name's <env> segment is explicit " +
118
+ "(chant #1703, decision 4). Run with --env <name>, set CHANT_ENV, or set a literal " +
119
+ "ownership.env in chant.config.ts.",
120
+ );
121
+ }
122
+ return { stack, env, namespace: namespace ?? receiptNamespaceFrom(undefined) };
123
+ }
124
+
125
+ /** The stored expectation on a live receipt ConfigMap, or undefined when the
126
+ * object holds none. */
127
+ export function receiptValueOf(object: K8sObject | undefined): string | undefined {
128
+ const data = (object as { data?: Record<string, unknown> } | undefined)?.data;
129
+ const value = data?.[RECEIPT_DATA_KEY];
130
+ return typeof value === "string" ? value : undefined;
131
+ }
132
+
133
+ /**
134
+ * The `ReceiptStore` over ConfigMaps. Bind it once in the op activities barrel
135
+ * as `receiptActivities(k8sReceiptStore())`, and the registry resolves
136
+ * `receiptRead`/`receiptWrite`/`receiptStaleness` by name, exactly like
137
+ * `ensureSecret` (#1830). Identity and cluster resolve lazily at first use, so
138
+ * module load never reads the project or connects to anything.
139
+ */
140
+ export function k8sReceiptStore(options: K8sReceiptStoreOptions = {}): ReceiptStore {
141
+ let identity: Promise<ReceiptIdentity> | undefined;
142
+ const identityOf = () => (identity ??= resolveIdentity(options));
143
+
144
+ let pending: Promise<K8sClient> | undefined;
145
+ const clientOf = (): Promise<K8sClient> =>
146
+ (pending ??= (options.connect ?? defaultK8sConnector)({
147
+ ...(options.environment !== undefined ? { environment: options.environment } : {}),
148
+ ...(options.context !== undefined ? { context: options.context } : {}),
149
+ ...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
150
+ }).then((connected) => connected.client));
151
+
152
+ return {
153
+ async read(receipt: EffectReceiptRef): Promise<string | undefined> {
154
+ const { stack, env, namespace } = await identityOf();
155
+ const ref = receiptConfigMapRef(stack, env, receipt.effect, namespace);
156
+ const client = await clientOf();
157
+ const live = await client.readIfPresent({ ...RECEIPT_CONFIGMAP_REF, ...ref });
158
+ return receiptValueOf(live);
159
+ },
160
+
161
+ async write(receipt: EffectReceiptRef, expectation: string): Promise<void> {
162
+ const { stack, env, namespace } = await identityOf();
163
+ const ref = receiptConfigMapRef(stack, env, receipt.effect, namespace);
164
+ const client = await clientOf();
165
+ const configMap: K8sObject = {
166
+ ...RECEIPT_CONFIGMAP_REF,
167
+ metadata: {
168
+ ...ref,
169
+ labels: {
170
+ ...ownershipEntries(LABEL_OWNERSHIP_KEYS, { stack, env }),
171
+ [RECEIPT_LABEL_KEY]: receipt.effect,
172
+ },
173
+ },
174
+ data: { [RECEIPT_DATA_KEY]: expectation },
175
+ } as K8sObject;
176
+ await client.apply(configMap, { fieldManager: `chant:${stack}` });
177
+ },
178
+ };
179
+ }
180
+
181
+ /** What the observation leg learned about the declared receipt rows. */
182
+ export interface ReceiptRowObservation {
183
+ resources: Record<string, ResourceMetadata>;
184
+ unobserved: Record<string, UnobservedEntity>;
185
+ }
186
+
187
+ /** The receipt rows a build output carries, keyed by entity name. Only the
188
+ * entities this observation was asked about. */
189
+ export function receiptRowsFor(
190
+ entityNames: readonly string[],
191
+ buildOutput: string | undefined,
192
+ ): Map<string, RenderedReceiptRow> {
193
+ const rows = parseReceiptComment(buildOutput ?? "");
194
+ const wanted = new Set(entityNames);
195
+ const out = new Map<string, RenderedReceiptRow>();
196
+ for (const [name, row] of Object.entries(rows)) {
197
+ if (!wanted.has(name)) continue;
198
+ if (typeof row?.name !== "string" || typeof row?.namespace !== "string") continue;
199
+ out.set(name, row);
200
+ }
201
+ return out;
202
+ }
203
+
204
+ /**
205
+ * The plan-side live read of the receipt rows (#2074's observation leg).
206
+ *
207
+ * A receipt is not a document the applier ever wrote (#1832), and it carries
208
+ * no `props` on the declared side, so the generic declared-entity sweep in
209
+ * ./describe-resources.ts has neither a `metadata.name` to query by nor an
210
+ * honest verdict to give. The serializer rendered each receipt's derived
211
+ * ConfigMap address into the build output's receipt comment, so this leg reads
212
+ * the addresses back from there, one derivation, decision 4, and asks the
213
+ * cluster for each.
214
+ *
215
+ * Present maps the stored value onto `attributes.value` (core's
216
+ * `RECEIPT_VALUE_ATTRIBUTE`); a genuine 404 is a real absence and stays one; a
217
+ * failed read is an `unobserved` hole, never a wrong answer: a receipt nobody
218
+ * could read must not arrive downstream as "the effect never ran".
219
+ */
220
+ export async function observeReceiptRows(
221
+ client: K8sClient,
222
+ rows: ReadonlyMap<string, RenderedReceiptRow>,
223
+ ): Promise<ReceiptRowObservation> {
224
+ const out: ReceiptRowObservation = { resources: {}, unobserved: {} };
225
+ await client.concurrently([...rows], async ([entityName, row]) => {
226
+ try {
227
+ const live = await client.read({
228
+ ...RECEIPT_CONFIGMAP_REF,
229
+ name: row.name,
230
+ namespace: row.namespace,
231
+ });
232
+ out.resources[entityName] = {
233
+ type: K8S_EFFECT_RECEIPT_ENTITY_TYPE,
234
+ physicalId: live.metadata?.uid,
235
+ // Live outside anything the applier wrote, by design. The same word
236
+ // the aws row's observation uses for a receipt parameter (#1835).
237
+ status: "EXTERNAL",
238
+ ownership: classifyOwnership(live.metadata?.labels, LABEL_OWNERSHIP_KEYS),
239
+ marker: readOwnership(live.metadata?.labels, LABEL_OWNERSHIP_KEYS),
240
+ attributes: {
241
+ namespace: row.namespace,
242
+ // Core's RECEIPT_VALUE_ATTRIBUTE, which is what `readReceiptValue` reads.
243
+ value: receiptValueOf(live) ?? "",
244
+ },
245
+ };
246
+ } catch (err) {
247
+ const outcome = classifyApiFailure(err);
248
+ if (outcome.kind === "unobserved") {
249
+ out.unobserved[entityName] = {
250
+ type: K8S_EFFECT_RECEIPT_ENTITY_TYPE,
251
+ reason: outcome.reason,
252
+ detail: `reading receipt ConfigMap ${row.namespace}/${row.name}: ${outcome.detail}`,
253
+ };
254
+ }
255
+ // `absent` records nothing: in neither map is how the contract spells
256
+ // "asked, and it is not there", which is what the plan reads as "the
257
+ // effect has not fired for these inputs".
258
+ }
259
+ });
260
+ return out;
261
+ }
262
+
263
+ /**
264
+ * The deep read's answer for the receipt rows.
265
+ *
266
+ * A receipt is read back here for the same reason the thin path reads it: a
267
+ * declared entity nobody looked at is a hole, and a hole in the deep read is
268
+ * noise on every `lifecycle diff --live --deep` a project with receipts runs.
269
+ * What it deliberately contributes is an EMPTY property tree: the declaration
270
+ * has no `props`, so every live path would land outside the claimed-field set
271
+ * (`@intentius/chant/claimed-fields`) and be reported unclaimed, and the
272
+ * receipt's stored value is not drift on any reading, because a stale receipt is an
273
+ * `effect` row from `planReceipts` (#1832), never an update. Presence and the
274
+ * uid are the whole of what the deep read has to say about a receipt.
275
+ */
276
+ export async function observeReceiptRowsDeep(
277
+ client: K8sClient,
278
+ rows: ReadonlyMap<string, RenderedReceiptRow>,
279
+ ): Promise<{ resources: Record<string, DeepResourceObservation>; unobserved: Record<string, UnobservedEntity> }> {
280
+ const thin = await observeReceiptRows(client, rows);
281
+ const resources: Record<string, DeepResourceObservation> = {};
282
+ for (const [name, meta] of Object.entries(thin.resources)) {
283
+ resources[name] = {
284
+ type: K8S_EFFECT_RECEIPT_ENTITY_TYPE,
285
+ ...(meta.physicalId ? { physicalId: meta.physicalId } : {}),
286
+ properties: {},
287
+ };
288
+ }
289
+ return { resources, unobserved: thin.unobserved };
290
+ }
@@ -1,6 +1,7 @@
1
1
  import { describe, test, expect } from "vitest";
2
2
  import { k8sSerializer } from "./serializer";
3
3
  import { DECLARABLE_MARKER } from "@intentius/chant/declarable";
4
+ import { heldElsewhere } from "@intentius/chant";
4
5
  import {
5
6
  defaultLabels,
6
7
  defaultAnnotations,
@@ -69,6 +70,31 @@ describe("k8sSerializer", () => {
69
70
  expect(result).toContain("replicas: 2");
70
71
  });
71
72
 
73
+ // #2162 — the honest example the issue names: an HPA owns `spec.replicas`
74
+ // on a Deployment after the first apply. `heldElsewhere()` omits the field
75
+ // from the manifest entirely, every apply, so the API server defaults it
76
+ // once at creation and the HPA is free to write it from there without
77
+ // chant's own apply fighting it back.
78
+ test("a heldElsewhere() replicas field never reaches the manifest", () => {
79
+ const entities = new Map<string, any>();
80
+ entities.set(
81
+ "myApp",
82
+ mockResource("K8s::Apps::Deployment", {
83
+ metadata: { name: "my-app", labels: { app: "my-app" } },
84
+ spec: {
85
+ replicas: heldElsewhere<number>({ by: "hpa", reason: "the autoscaler owns replicas after the first apply" }),
86
+ selector: { matchLabels: { app: "my-app" } },
87
+ },
88
+ }),
89
+ );
90
+
91
+ const result = k8sSerializer.serialize(entities);
92
+ expect(result).toContain("kind: Deployment");
93
+ expect(result).toContain("selector:");
94
+ expect(result).not.toContain("replicas");
95
+ expect(result).not.toContain("heldElsewhere");
96
+ });
97
+
72
98
  test("metadata.name auto-generated from logical name (camelCase→kebab-case)", () => {
73
99
  const entities = new Map<string, any>();
74
100
  entities.set(
package/src/serializer.ts CHANGED
@@ -10,6 +10,21 @@ import type { Declarable } from "@intentius/chant/declarable";
10
10
  import { isPropertyDeclarable, isResourceDeclarable } from "@intentius/chant/declarable";
11
11
  import type { Serializer, SerializerResult, SerializeContext } from "@intentius/chant/serializer";
12
12
  import { ownershipEntries, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
13
+ import {
14
+ isEffectReceipt,
15
+ receiptExpectation,
16
+ referenceInputPaths,
17
+ type EffectReceiptDeclaration,
18
+ } from "@intentius/chant/effect-receipt";
19
+ import {
20
+ RECEIPT_CONFIGMAP_REF,
21
+ RECEIPT_DATA_KEY,
22
+ RECEIPT_UNRESOLVED_VALUE_NOTE,
23
+ receiptConfigMapRef,
24
+ receiptNamespaceFrom,
25
+ renderReceiptComment,
26
+ type RenderedReceiptRow,
27
+ } from "./effect-receipt-row";
13
28
  import type { LexiconOutput } from "@intentius/chant/lexicon-output";
14
29
  import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
15
30
  import { emitYAML } from "@intentius/chant/yaml";
@@ -180,6 +195,65 @@ function resolveK8sAttr(entity: Declarable | undefined, logicalName: string, att
180
195
  );
181
196
  }
182
197
 
198
+ /** The rendered expectation: the synthesis-time value when the receipt is
199
+ * fully static, the placeholder note when reference inputs remain (#1703
200
+ * decision 5, since synthesis resolves nothing). */
201
+ function receiptRowValue(receipt: EffectReceiptDeclaration): string {
202
+ if (receipt.flavor === "hash" && referenceInputPaths(receipt).length > 0) {
203
+ return RECEIPT_UNRESOLVED_VALUE_NOTE;
204
+ }
205
+ return receiptExpectation(receipt);
206
+ }
207
+
208
+ /**
209
+ * Render the effect receipts (#2074) the build withheld from the apply-bound
210
+ * entity set (`SerializeContext.receipts`, #1832) as ConfigMap rows: named
211
+ * `chant-receipt.<stack>.<env>.<effect>` from the ownership marker fields
212
+ * (epic decision 4), in the project's receipt namespace, with the expectation
213
+ * under `data.expectation`.
214
+ *
215
+ * Visibility only. The rows ride a YAML comment at the end of the manifest
216
+ * stream (./effect-receipt-row.ts) rather than a document, because a document
217
+ * is what an applier applies and the `effect()` step is a receipt's sole
218
+ * writer (#1832, epic #1703 decision 3).
219
+ *
220
+ * The env segment is explicit: a receipt with no resolved `ownership.env` is
221
+ * an error, never a guessed segment.
222
+ */
223
+ function renderReceiptRows(
224
+ receipts: ReadonlyMap<string, Declarable>,
225
+ ownership: { stack: string; env?: string } | undefined,
226
+ namespace: string,
227
+ ): Record<string, RenderedReceiptRow> {
228
+ const rows: Record<string, RenderedReceiptRow> = {};
229
+ const names = [...receipts.keys()].join(", ");
230
+ if (!ownership?.stack) {
231
+ throw new Error(
232
+ `k8s receipts (${names}): no ownership marker resolved. The receipt ConfigMap is named ` +
233
+ `chant-receipt.<stack>.<env>.<effect>, derived from the same ownership fields that ` +
234
+ `stamp markers (chant #1703, decision 4). Set ownership: { stack } in chant.config.ts.`,
235
+ );
236
+ }
237
+ if (!ownership.env) {
238
+ throw new Error(
239
+ `k8s receipts (${names}): ownership resolved no env. The receipt name's <env> segment is ` +
240
+ `explicit (chant #1703, decision 4). Set ownership: { env } in chant.config.ts, or ` +
241
+ `build with an env-valued parameter that resolves it.`,
242
+ );
243
+ }
244
+ for (const [name, entity] of receipts) {
245
+ if (!isEffectReceipt(entity)) continue;
246
+ const ref = receiptConfigMapRef(ownership.stack, ownership.env, entity.effect, namespace);
247
+ rows[name] = {
248
+ kind: RECEIPT_CONFIGMAP_REF.kind,
249
+ namespace: ref.namespace,
250
+ name: ref.name,
251
+ data: { [RECEIPT_DATA_KEY]: receiptRowValue(entity) },
252
+ };
253
+ }
254
+ return rows;
255
+ }
256
+
183
257
  /**
184
258
  * K8s visitor for the generic serializer walker.
185
259
  */
@@ -416,7 +490,24 @@ export const k8sSerializer: Serializer = {
416
490
  }
417
491
  }
418
492
 
419
- const primary = [...namespaceDocs, ...otherDocs].join("\n---\n");
493
+ let primary = [...namespaceDocs, ...otherDocs].join("\n---\n");
494
+
495
+ // Effect receipt rows (#2074): visibility only, deliberately NOT a
496
+ // document: appliers apply documents, and the `effect()` step is a
497
+ // receipt's sole writer (#1832, epic #1703 decision 3). The comment rides
498
+ // the same build output the observation leg is handed, which is how it
499
+ // learns each receipt's ConfigMap address.
500
+ if (context?.receipts && context.receipts.size > 0) {
501
+ const rows = renderReceiptRows(
502
+ context.receipts,
503
+ context.ownership,
504
+ receiptNamespaceFrom(context.config),
505
+ );
506
+ if (Object.keys(rows).length > 0) {
507
+ primary = primary.length > 0 ? `${primary}\n${renderReceiptComment(rows)}\n` : `${renderReceiptComment(rows)}\n`;
508
+ }
509
+ }
510
+
420
511
  // A bare string when there is nothing extra to write, so the common case
421
512
  // stays byte-identical to what every existing consumer already reads.
422
513
  if (Object.keys(files).length === 0 && warnings.length === 0) return primary;
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  skill: chant-k8s-argo
3
- description: Argo CD composites for GitOps reconciliation — ArgoAppFor, ArgoAppSetForRegions, AppProject scoping, cluster registration, and the Argo-vs-Temporal split
3
+ description: Argo CD composites for GitOps reconciliation — ArgoAppFor, ArgoAppSetForRegions, AppProject scoping, cluster registration, and how a deploy splits between Argo and a chant Op
4
4
  user-invocable: true
5
5
  ---
6
6
 
@@ -14,9 +14,9 @@ Chant authors typed infrastructure into manifests. Argo CD continuously reconcil
14
14
  |---|---|---|
15
15
  | **Chant** | Authoring typed infra → manifests | the lexicons |
16
16
  | **Argo CD** | Continuously reconciling declarative manifests (the apply layer) | `ArgoAppFor` / `ArgoAppSetForRegions` |
17
- | **Temporal** | Procedural steps Argo can't express ordering, signals, human gates, one-shot RPCs | the temporal lexicon + `waitForArgoSync` |
17
+ | **A chant Op** | Procedural steps Argo can't express: ordering, human gates, one-shot RPCs | an `Op` in the project, plus this lexicon's `waitForArgoSync` |
18
18
 
19
- Rule of thumb: **if it's declarative and converges, let Argo reconcile it. If it's a procedure with ordering, gates, or out-of-band steps, orchestrate it in Temporal.** Prefer Argo CD over Argo Workflows the procedural layer stays Temporal.
19
+ Rule of thumb: **if it's declarative and converges, let Argo reconcile it. If it's a procedure with ordering, gates, or out-of-band steps, write it as a chant Op and run it from CI or a steward.** Prefer Argo CD over Argo Workflows; the procedural layer stays an Op.
20
20
 
21
21
  ## Prerequisites
22
22
 
@@ -138,9 +138,9 @@ Produces a `Secret` labelled `argocd.argoproj.io/secret-type: cluster`. After th
138
138
 
139
139
  ---
140
140
 
141
- ## The Argo-vs-Temporal split
141
+ ## Splitting a deploy between Argo and an Op
142
142
 
143
- When a deploy has both declarative and procedural parts, let each layer own what it's good at. Example the multi-region CockroachDB deploy:
143
+ When a deploy has both declarative and procedural parts, let each layer own what it's good at. Example, the multi-region CockroachDB deploy:
144
144
 
145
145
  | Step | Owner | Why |
146
146
  |---|---|---|
@@ -148,16 +148,30 @@ When a deploy has both declarative and procedural parts, let each layer own what
148
148
  | Install ESO / operators (Helm) | **Argo** | Declarative Helm source |
149
149
  | Apply per-cluster K8s manifests | **Argo** (`ApplicationSet`) | One App per workload cluster |
150
150
  | Wait for workloads Healthy | **Argo** (`Health=Healthy`) | Subsumed by Application health |
151
- | Wait for DNS delegation | **Temporal** | Signal/update/auto-poll race out of band |
152
- | Generate + push TLS certs | **Temporal** | One-shot procedure, secrets not in git |
153
- | `cockroach init`, configure regions | **Temporal** | Ordered one-shot RPCs |
154
-
155
- From a Temporal workflow, gate procedural steps on Argo finishing a declarative apply with the `waitForArgoSync` activity (temporal lexicon, `argoSync` profile):
151
+ | Wait for DNS delegation | **an Op** | Out of band, and a human confirms it |
152
+ | Generate + push TLS certs | **an Op** | One-shot procedure, secrets not in git |
153
+ | `cockroach init`, configure regions | **an Op** | Ordered one-shot RPCs |
154
+
155
+ Argo owns the sync. The Op owns the ordering and the gates: its phases run in
156
+ sequence in one process (`packages/core/src/op/local-executor.ts`), and a `gate`
157
+ step reads the gate ledger, so a run that reaches a gate nobody has approved
158
+ records the pending fact, ends with status `gated` and exits 3. Someone runs
159
+ `chant approve <op> <gate>`, the next run reads the resolution and walks
160
+ through. CI is what runs the Op, on whatever cadence the Op's `schedule`
161
+ names.
162
+
163
+ To make a step wait on Argo, use this lexicon's `waitForArgoSync` activity. It is
164
+ exported from `lexicons/k8s/src/op/activities/index.ts`, and the core activity
165
+ registry resolves it by export name once `k8s` is in the project's `lexicons`.
166
+ Give the step core's `argoSync` profile
167
+ (`packages/core/src/op/activity-profiles.ts`): a 30m timeout, five attempts
168
+ backing off from 10s, and `ArgoSyncFailedError` marked non-retryable so a
169
+ terminally unhealthy Application fails fast instead of polling to the cap.
156
170
 
157
171
  ```typescript
158
- // In a Temporal Op workflow:
159
- await waitForArgoSync({ appName: "east-crdb", namespace: "argocd" });
160
- // ...now run the procedural steps that depend on the workloads being Healthy.
172
+ // In an Op phase:
173
+ activity("waitForArgoSync", { appName: "east-crdb", namespace: "argocd" }, "argoSync"),
174
+ // Later steps in the phase run once the workloads are Healthy.
161
175
  ```
162
176
 
163
177
  `waitForArgoSync` is dependency-free — it polls the Application's status (`health=Healthy && sync=Synced`) and never imports the Argo CRD types.