@intentius/chant-lexicon-k8s 0.58.0 → 0.60.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 (61) 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/codegen/docs.d.ts.map +1 -1
  4. package/dist/composites/cron-schedule.d.ts +26 -0
  5. package/dist/composites/cron-schedule.d.ts.map +1 -0
  6. package/dist/composites/cron-workload.d.ts +1 -1
  7. package/dist/composites/cron-workload.d.ts.map +1 -1
  8. package/dist/composites/operator-stack.d.ts +1 -1
  9. package/dist/composites/operator-stack.d.ts.map +1 -1
  10. package/dist/config-schema.d.ts +3 -0
  11. package/dist/config-schema.d.ts.map +1 -1
  12. package/dist/config.d.ts +22 -0
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/deep-observe.d.ts.map +1 -1
  15. package/dist/describe-resources.d.ts.map +1 -1
  16. package/dist/effect-receipt-row.d.ts +164 -0
  17. package/dist/effect-receipt-row.d.ts.map +1 -0
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/integrity.json +3 -3
  21. package/dist/lint/post-synth/wk8505.d.ts +3 -1
  22. package/dist/lint/post-synth/wk8505.d.ts.map +1 -1
  23. package/dist/manifest.json +1 -1
  24. package/dist/okf/index.md +1 -1
  25. package/dist/okf/rules/WK8505.md +2 -2
  26. package/dist/okf/types/Kustomization.md +1 -1
  27. package/dist/op/activities/index.d.ts +6 -0
  28. package/dist/op/activities/index.d.ts.map +1 -1
  29. package/dist/op/activities/kubectl.d.ts.map +1 -1
  30. package/dist/plugin.d.ts.map +1 -1
  31. package/dist/receipt-store.d.ts +113 -0
  32. package/dist/receipt-store.d.ts.map +1 -0
  33. package/dist/rules/wk8505.ts +4 -2
  34. package/dist/serializer.d.ts.map +1 -1
  35. package/dist/subscribe-changes.d.ts +84 -0
  36. package/dist/subscribe-changes.d.ts.map +1 -0
  37. package/package.json +3 -3
  38. package/src/api/fake-cluster.ts +17 -4
  39. package/src/codegen/docs.ts +7 -0
  40. package/src/composites/composites.test.ts +59 -1
  41. package/src/composites/cron-schedule.ts +47 -0
  42. package/src/composites/cron-workload.ts +4 -1
  43. package/src/composites/operator-stack.ts +3 -4
  44. package/src/config-schema.ts +5 -0
  45. package/src/config.ts +23 -0
  46. package/src/deep-observe.ts +26 -7
  47. package/src/describe-resources.ts +31 -7
  48. package/src/effect-receipt-row.test.ts +285 -0
  49. package/src/effect-receipt-row.ts +268 -0
  50. package/src/index.ts +22 -0
  51. package/src/lint/audit-catalog.ts +1 -1
  52. package/src/lint/post-synth/wk8505.ts +4 -2
  53. package/src/op/activities/index.ts +14 -0
  54. package/src/op/activities/kubectl.test.ts +38 -0
  55. package/src/op/activities/kubectl.ts +15 -0
  56. package/src/plugin.ts +15 -0
  57. package/src/receipt-store.test.ts +380 -0
  58. package/src/receipt-store.ts +290 -0
  59. package/src/serializer.ts +92 -1
  60. package/src/subscribe-changes.test.ts +368 -0
  61. package/src/subscribe-changes.ts +210 -0
@@ -0,0 +1,113 @@
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
+ import type { ReceiptStore } from "@intentius/chant/op/receipt-store";
33
+ import type { ResourceMetadata, UnobservedEntity } from "@intentius/chant/lexicon";
34
+ import type { DeepResourceObservation } from "@intentius/chant/deep-observation";
35
+ import type { K8sClient, K8sObject } from "@intentius/chant-k8s-client";
36
+ import { type K8sConnector } from "./api/connect.js";
37
+ import { type RenderedReceiptRow } from "./effect-receipt-row.js";
38
+ /** Options for {@link k8sReceiptStore}. All optional: the default store reads
39
+ * its identity and its namespace from the project, and its cluster from the
40
+ * environment binding. */
41
+ export interface K8sReceiptStoreOptions {
42
+ /** The name's `<stack>` segment. Omitted, the project's `ownership.stack`
43
+ * (chant.config.ts, found upward from `cwd`) answers. */
44
+ stack?: string;
45
+ /** The name's `<env>` segment, explicit by decision 4. Omitted, `CHANT_ENV`
46
+ * (set by `chant run --env`) answers, then a literal `ownership.env`. */
47
+ environment?: string;
48
+ /** Namespace the receipts live in. Omitted, `k8s.receipts.namespace`
49
+ * answers, then `default`. */
50
+ namespace?: string;
51
+ /** Where to look for chant.config.ts. Defaults to the working directory. */
52
+ cwd?: string;
53
+ /** Explicit kubectl context, for a caller that already resolved one. */
54
+ context?: string;
55
+ /** The connector to build a client with. Test seam. */
56
+ connect?: K8sConnector;
57
+ /** Environment record the identity fallback reads. Defaults to `process.env`. */
58
+ env?: Record<string, string | undefined>;
59
+ }
60
+ /** The stored expectation on a live receipt ConfigMap, or undefined when the
61
+ * object holds none. */
62
+ export declare function receiptValueOf(object: K8sObject | undefined): string | undefined;
63
+ /**
64
+ * The `ReceiptStore` over ConfigMaps. Bind it once in the op activities barrel
65
+ * as `receiptActivities(k8sReceiptStore())`, and the registry resolves
66
+ * `receiptRead`/`receiptWrite`/`receiptStaleness` by name, exactly like
67
+ * `ensureSecret` (#1830). Identity and cluster resolve lazily at first use, so
68
+ * module load never reads the project or connects to anything.
69
+ */
70
+ export declare function k8sReceiptStore(options?: K8sReceiptStoreOptions): ReceiptStore;
71
+ /** What the observation leg learned about the declared receipt rows. */
72
+ export interface ReceiptRowObservation {
73
+ resources: Record<string, ResourceMetadata>;
74
+ unobserved: Record<string, UnobservedEntity>;
75
+ }
76
+ /** The receipt rows a build output carries, keyed by entity name. Only the
77
+ * entities this observation was asked about. */
78
+ export declare function receiptRowsFor(entityNames: readonly string[], buildOutput: string | undefined): Map<string, RenderedReceiptRow>;
79
+ /**
80
+ * The plan-side live read of the receipt rows (#2074's observation leg).
81
+ *
82
+ * A receipt is not a document the applier ever wrote (#1832), and it carries
83
+ * no `props` on the declared side, so the generic declared-entity sweep in
84
+ * ./describe-resources.ts has neither a `metadata.name` to query by nor an
85
+ * honest verdict to give. The serializer rendered each receipt's derived
86
+ * ConfigMap address into the build output's receipt comment, so this leg reads
87
+ * the addresses back from there, one derivation, decision 4, and asks the
88
+ * cluster for each.
89
+ *
90
+ * Present maps the stored value onto `attributes.value` (core's
91
+ * `RECEIPT_VALUE_ATTRIBUTE`); a genuine 404 is a real absence and stays one; a
92
+ * failed read is an `unobserved` hole, never a wrong answer: a receipt nobody
93
+ * could read must not arrive downstream as "the effect never ran".
94
+ */
95
+ export declare function observeReceiptRows(client: K8sClient, rows: ReadonlyMap<string, RenderedReceiptRow>): Promise<ReceiptRowObservation>;
96
+ /**
97
+ * The deep read's answer for the receipt rows.
98
+ *
99
+ * A receipt is read back here for the same reason the thin path reads it: a
100
+ * declared entity nobody looked at is a hole, and a hole in the deep read is
101
+ * noise on every `lifecycle diff --live --deep` a project with receipts runs.
102
+ * What it deliberately contributes is an EMPTY property tree: the declaration
103
+ * has no `props`, so every live path would land outside the claimed-field set
104
+ * (`@intentius/chant/claimed-fields`) and be reported unclaimed, and the
105
+ * receipt's stored value is not drift on any reading, because a stale receipt is an
106
+ * `effect` row from `planReceipts` (#1832), never an update. Presence and the
107
+ * uid are the whole of what the deep read has to say about a receipt.
108
+ */
109
+ export declare function observeReceiptRowsDeep(client: K8sClient, rows: ReadonlyMap<string, RenderedReceiptRow>): Promise<{
110
+ resources: Record<string, DeepResourceObservation>;
111
+ unobserved: Record<string, UnobservedEntity>;
112
+ }>;
113
+ //# sourceMappingURL=receipt-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"receipt-store.d.ts","sourceRoot":"","sources":["../src/receipt-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AASH,OAAO,KAAK,EAAoB,YAAY,EAAE,MAAM,mCAAmC,CAAC;AACxF,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AACnF,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,mCAAmC,CAAC;AACjF,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,6BAA6B,CAAC;AACxE,OAAO,EAAuB,KAAK,YAAY,EAAE,MAAM,eAAe,CAAC;AAEvE,OAAO,EAQL,KAAK,kBAAkB,EACxB,MAAM,sBAAsB,CAAC;AAE9B;;0BAE0B;AAC1B,MAAM,WAAW,sBAAsB;IACrC;6DACyD;IACzD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;6EACyE;IACzE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;kCAC8B;IAC9B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,4EAA4E;IAC5E,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,wEAAwE;IACxE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,uDAAuD;IACvD,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,iFAAiF;IACjF,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;CAC1C;AA+CD;wBACwB;AACxB,wBAAgB,cAAc,CAAC,MAAM,EAAE,SAAS,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAIhF;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,OAAO,GAAE,sBAA2B,GAAG,YAAY,CAuClF;AAED,wEAAwE;AACxE,MAAM,WAAW,qBAAqB;IACpC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IAC5C,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;CAC9C;AAED;gDACgD;AAChD,wBAAgB,cAAc,CAC5B,WAAW,EAAE,SAAS,MAAM,EAAE,EAC9B,WAAW,EAAE,MAAM,GAAG,SAAS,GAC9B,GAAG,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAUjC;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,SAAS,EACjB,IAAI,EAAE,WAAW,CAAC,MAAM,EAAE,kBAAkB,CAAC,GAC5C,OAAO,CAAC,qBAAqB,CAAC,CAsChC;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,sBAAsB,CAC1C,MAAM,EAAE,SAAS,EACjB,IAAI,EAAE,WAAW,CAAC,MAAM,EAAE,kBAAkB,CAAC,GAC5C,OAAO,CAAC;IAAE,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,uBAAuB,CAAC,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAA;CAAE,CAAC,CAW/G"}
@@ -15,7 +15,9 @@
15
15
  * invocations, so "no Kustomization in THIS build sets spec.decryption" can
16
16
  * mean "wired up in the other build" as easily as "forgotten" — the design
17
17
  * doc's §4 explains why no path-to-build-target join exists to tell those
18
- * apart. Promote to error if that join ever becomes available.
18
+ * apart. Single-build-root scoped, tracked by chant #1939 as a candidate for
19
+ * an opt-in project-level mode; promote to error if that join ever becomes
20
+ * available.
19
21
  *
20
22
  * Fires only on claims that actually resolved (`problems.length === 0`, via
21
23
  * `resolveEncryptedSecretClaims`), not on every raw declaration. An
@@ -39,7 +41,7 @@ function fluxKustomizations(manifests: K8sManifest[]): K8sManifest[] {
39
41
  export const wk8505: PostSynthCheck = {
40
42
  id: "WK8505",
41
43
  description:
42
- "committed-encrypted secret with no Flux decryption wiring — add decryption: 'sops' to the FluxAppFor reconciling the path that carries it",
44
+ "committed-encrypted secret with no Flux decryption wiring — add decryption: 'sops' to the FluxAppFor reconciling the path that carries it. Sees one build root at a time, so it goes silent when that Kustomization lives in a different build root (chant #1939).",
43
45
 
44
46
  check(ctx: PostSynthContext): PostSynthDiagnostic[] {
45
47
  const kustomizations = fluxKustomizations(allManifests(ctx));
@@ -1 +1 @@
1
- {"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAKH,OAAO,KAAK,EAAE,UAAU,EAAsC,MAAM,6BAA6B,CAAC;AA+MlG;;GAEG;AACH,eAAO,MAAM,aAAa,EAAE,UAiN3B,CAAC"}
1
+ {"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAKH,OAAO,KAAK,EAAE,UAAU,EAAsC,MAAM,6BAA6B,CAAC;AAyRlG;;GAEG;AACH,eAAO,MAAM,aAAa,EAAE,UAkO3B,CAAC"}
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The k8s change signal, `LexiconPlugin.subscribeChanges` (chant #1981).
3
+ *
4
+ * Kubernetes is the one substrate chant reaches where a change stream is
5
+ * complete, trustworthy, and needs nothing deployed into the cluster being
6
+ * observed: the Watch API is served for every kind the API server serves, is
7
+ * `resourceVersion`-based so a reconnect has a defined resume point, and is
8
+ * authorized by the same read credentials `describeResources` already uses.
9
+ * Every cloud substrate fails on that last point. Subscribing to EventBridge,
10
+ * Cloud Asset Inventory or Event Grid means writing infrastructure into the
11
+ * account being watched, which inverts the property that makes a read-only
12
+ * watch safe to point at production. The verdict table in the operator guide
13
+ * records that per lexicon; this file is the one place it came out `yes`.
14
+ *
15
+ * ## What this is allowed to conclude: nothing
16
+ *
17
+ * A watch event never becomes an observation. The frames are read, and then
18
+ * discarded. The only thing that leaves this module is a no-argument
19
+ * `onChange()`, which wakes an operator tick that re-observes the estate from
20
+ * scratch through the ordinary read path. There is no code here that could
21
+ * turn a `DELETED` frame into a proposed `create`, because there is no channel
22
+ * from a frame to anything but a function call with no parameters.
23
+ *
24
+ * That is also why a missed event costs nothing. A `410 Gone`, a dropped
25
+ * connection, a subscription that never got established: all of them slow
26
+ * detection back to the operator's timer, and none of them make the estate
27
+ * read as clean.
28
+ *
29
+ * ## Scope
30
+ *
31
+ * One watch per (kind, namespace) the declared entities name. The kinds come
32
+ * from the same generated operation surface `describeResources` addresses
33
+ * entities through; the namespaces come from the declarations themselves,
34
+ * falling back to the client's own default for a namespaced entity that
35
+ * declares none. Nothing widens that: a project declaring three Deployments in
36
+ * one namespace opens one connection, not a cluster-wide firehose.
37
+ */
38
+ import type { ChangeSubscription, SubscribeChangesOptions } from "@intentius/chant/lexicon";
39
+ import { type K8sConnector } from "./api/connect.js";
40
+ /**
41
+ * The most connections one subscription will hold open.
42
+ *
43
+ * A watch is a long-lived HTTP/2 stream against the API server, and one per
44
+ * (kind, namespace) is cheap right up until an estate declares eighty kinds
45
+ * across a dozen namespaces. Past this ceiling the honest move is to refuse
46
+ * the whole subscription and say so, rather than to open some arbitrary
47
+ * prefix of it: a partial watch is a signal that goes quiet for exactly the
48
+ * resources nobody chose to drop. The operator then runs on its timer, which
49
+ * is what it did before this existed.
50
+ */
51
+ export declare const MAX_WATCHES = 32;
52
+ /** One thing to watch: a kind, and the namespace to watch it in. */
53
+ interface WatchTarget {
54
+ apiVersion: string;
55
+ kind: string;
56
+ /** Absent for a cluster-scoped kind. */
57
+ namespace?: string;
58
+ }
59
+ /**
60
+ * The distinct (kind, namespace) pairs a declared estate implies.
61
+ *
62
+ * Deterministic order, so a refusal past {@link MAX_WATCHES} names the same
63
+ * scope every time and a test can assert on it.
64
+ */
65
+ export declare function watchTargets(entities: Map<string, {
66
+ entityType: string;
67
+ props: Record<string, unknown>;
68
+ }>, defaultNamespace: string): {
69
+ targets: WatchTarget[];
70
+ unaddressable: string[];
71
+ };
72
+ /**
73
+ * Open one watch per declared (kind, namespace) and report every event as a
74
+ * bare `onChange()`.
75
+ *
76
+ * Throws only for a failure that makes the whole subscription impossible: no
77
+ * entities in scope, a cluster binding that will not resolve, a scope past the
78
+ * ceiling. The operator turns that into one logged line and keeps polling.
79
+ * Once the subscription is live, nothing throws: a watch that dies reports
80
+ * through `onError` and the operator re-subscribes on its next round.
81
+ */
82
+ export declare function subscribeChanges(options: SubscribeChangesOptions, connect?: K8sConnector): Promise<ChangeSubscription>;
83
+ export {};
84
+ //# sourceMappingURL=subscribe-changes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"subscribe-changes.d.ts","sourceRoot":"","sources":["../src/subscribe-changes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,OAAO,KAAK,EAAE,kBAAkB,EAAE,uBAAuB,EAAE,MAAM,0BAA0B,CAAC;AAE5F,OAAO,EAAuB,KAAK,YAAY,EAAE,MAAM,eAAe,CAAC;AAGvE;;;;;;;;;;GAUG;AACH,eAAO,MAAM,WAAW,KAAK,CAAC;AAE9B,oEAAoE;AACpE,UAAU,WAAW;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,wCAAwC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAC1B,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAAC,EAC7E,gBAAgB,EAAE,MAAM,GACvB;IAAE,OAAO,EAAE,WAAW,EAAE,CAAC;IAAC,aAAa,EAAE,MAAM,EAAE,CAAA;CAAE,CAkCrD;AAOD;;;;;;;;;GASG;AACH,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,uBAAuB,EAChC,OAAO,GAAE,YAAkC,GAC1C,OAAO,CAAC,kBAAkB,CAAC,CAkF7B"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-k8s",
3
- "version": "0.58.0",
3
+ "version": "0.60.0",
4
4
  "description": "Kubernetes lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -76,7 +76,7 @@
76
76
  "@types/js-yaml": "^4.0.9"
77
77
  },
78
78
  "optionalDependencies": {
79
- "@intentius/chant-k8s-client": "^0.58.0"
79
+ "@intentius/chant-k8s-client": "^0.60.0"
80
80
  },
81
81
  "devDependencies": {
82
82
  "@intentius/chant": "*",
@@ -84,7 +84,7 @@
84
84
  },
85
85
  "peerDependencies": {
86
86
  "zod": "^4.3.6",
87
- "@intentius/chant": "^0.58.0",
87
+ "@intentius/chant": "^0.60.0",
88
88
  "typescript": "^5.9.3"
89
89
  }
90
90
  }
@@ -15,7 +15,7 @@
15
15
 
16
16
  import type { K8sObject } from "@intentius/chant-k8s-client";
17
17
  import { apiResourceList, fakeKubeconfig, fakeRequestLayer, statusBody } from "@intentius/chant-k8s-client/testing";
18
- import type { FakeRequestLayer, RecordedRequest } from "@intentius/chant-k8s-client/testing";
18
+ import type { FakeRequestLayer, FakeResponse, RecordedRequest } from "@intentius/chant-k8s-client/testing";
19
19
  import { createK8sClient } from "@intentius/chant-k8s-client";
20
20
  import type { ConnectedClient, ConnectOptions, K8sConnector } from "./connect";
21
21
  import { operationTable } from "./operation-surface";
@@ -33,8 +33,12 @@ export interface FakeClusterOptions {
33
33
  * CRD resolvable in a test without registering anything.
34
34
  */
35
35
  serves?: readonly string[];
36
- /** Full control: return a response for a request, or undefined to fall through. */
37
- respond?: (request: RecordedRequest) => { status?: number; body?: unknown } | undefined;
36
+ /**
37
+ * Full control: return a response for a request, or undefined to fall
38
+ * through. A `stream` on the response is what a watch reads (chant #1981);
39
+ * see `fakeWatchStream` in the client's testing harness.
40
+ */
41
+ respond?: (request: RecordedRequest) => FakeResponse | undefined;
38
42
  /** Kubeconfig to hand the client. Defaults to a single-context one. */
39
43
  kubeconfig?: string;
40
44
  }
@@ -177,7 +181,16 @@ export function fakeCluster(options: FakeClusterOptions = {}): FakeCluster {
177
181
  if (object) return { body: object };
178
182
 
179
183
  if (isListPath(request.path, resources)) {
180
- return { body: { kind: "List", items: listPaths.get(request.path) ?? [], metadata: {} } };
184
+ // A `resourceVersion` on every list: it is what a watch resumes from
185
+ // (chant #1981), and a list without one is not a shape any API server
186
+ // produces.
187
+ return {
188
+ body: {
189
+ kind: "List",
190
+ items: listPaths.get(request.path) ?? [],
191
+ metadata: { resourceVersion: "1" },
192
+ },
193
+ };
181
194
  }
182
195
 
183
196
  return { status: 404, body: statusBody(404, "NotFound", `${request.path} not found`) };
@@ -87,6 +87,13 @@ The generated file includes:
87
87
  - \`metadata.name\` auto-generated from export names (camelCase → kebab-case)
88
88
  - Default labels and annotations injected from \`defaultLabels()\`/\`defaultAnnotations()\`
89
89
 
90
+ The lexicon also materializes [effect receipts](/chant/concepts/effect-receipts/) as
91
+ ConfigMaps named \`chant-receipt.<stack>.<env>.<effect>\` in \`k8s.receipts.namespace\`
92
+ (\`default\` when unset), holding the expectation under \`data.expectation\`. A receipt is
93
+ never a document in the manifest stream: the rows ride a trailing
94
+ \`# chant:effect-receipts\` comment, which \`kubectl apply\` ignores, because the
95
+ \`effect()\` step is a receipt's sole writer.
96
+
90
97
  ## Key conversions
91
98
 
92
99
  | Chant (TypeScript) | YAML output | Rule |
@@ -340,6 +340,32 @@ describe("CronWorkload", () => {
340
340
  expect((p(result.role).metadata as any).labels["app.kubernetes.io/component"]).toBe("rbac");
341
341
  expect((p(result.roleBinding).metadata as any).labels["app.kubernetes.io/component"]).toBe("rbac");
342
342
  });
343
+
344
+ // ── schedule validation (#2071 item 4) ────────────────────────────
345
+
346
+ test("accepts a valid five-field schedule", () => {
347
+ expect(() =>
348
+ CronWorkload({ name: "backup", image: "backup:1.0", schedule: "0 2 * * *" }),
349
+ ).not.toThrow();
350
+ });
351
+
352
+ test("refuses a 6-field (seconds) schedule, naming the field, the value, and the CronJob limit", () => {
353
+ expect(() =>
354
+ CronWorkload({ name: "backup", image: "backup:1.0", schedule: "0 0 2 * * *" }),
355
+ ).toThrow(/schedule "0 0 2 \* \* \*" is 6-field cron.*five fields/s);
356
+ });
357
+
358
+ test("refuses a malformed schedule", () => {
359
+ expect(() =>
360
+ CronWorkload({ name: "backup", image: "backup:1.0", schedule: "not a cron" }),
361
+ ).toThrow(/schedule "not a cron" is not valid cron syntax/);
362
+ });
363
+
364
+ test("refuses a blank schedule", () => {
365
+ expect(() =>
366
+ CronWorkload({ name: "backup", image: "backup:1.0", schedule: "" }),
367
+ ).toThrow(/schedule is required/);
368
+ });
343
369
  });
344
370
 
345
371
  // ── AutoscaledService ──────────────────────────────────────────────
@@ -4016,7 +4042,39 @@ describe("OperatorStack", () => {
4016
4042
  image: "chant:latest",
4017
4043
  converge: [{ name: "fountain-observe", schedule: "", env: "staging" }],
4018
4044
  }),
4019
- ).toThrow(/schedule/);
4045
+ ).toThrow(/schedule is required/);
4046
+ });
4047
+
4048
+ // ── schedule validation (#2071 item 4) ────────────────────────────
4049
+
4050
+ test("accepts a valid five-field schedule", () => {
4051
+ expect(() =>
4052
+ OperatorStack({
4053
+ name: "chant-operator",
4054
+ image: "chant:latest",
4055
+ converge: [{ name: "fountain-observe", schedule: "*/10 * * * *", env: "staging" }],
4056
+ }),
4057
+ ).not.toThrow();
4058
+ });
4059
+
4060
+ test("refuses a host with a 6-field (seconds) schedule, naming the field, the value, and the CronJob limit", () => {
4061
+ expect(() =>
4062
+ OperatorStack({
4063
+ name: "chant-operator",
4064
+ image: "chant:latest",
4065
+ converge: [{ name: "fountain-observe", schedule: "0 */10 * * * *", env: "staging" }],
4066
+ }),
4067
+ ).toThrow(/schedule "0 \*\/10 \* \* \* \*" is 6-field cron.*five fields/s);
4068
+ });
4069
+
4070
+ test("refuses a host with a malformed schedule", () => {
4071
+ expect(() =>
4072
+ OperatorStack({
4073
+ name: "chant-operator",
4074
+ image: "chant:latest",
4075
+ converge: [{ name: "fountain-observe", schedule: "not a cron", env: "staging" }],
4076
+ }),
4077
+ ).toThrow(/schedule "not a cron" is not valid cron syntax/);
4020
4078
  });
4021
4079
 
4022
4080
  test("refuses a blank image", () => {
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Shared CronJob schedule validation (issue #2071 item 4).
3
+ *
4
+ * `OperatorStack` and `CronWorkload` both hand their `schedule` field
5
+ * straight to a k8s `CronJob`, which parses only five fields (minute hour
6
+ * day-of-month month day-of-week). Core's own cron parser
7
+ * (`isValidCronExpression`, `packages/core/src/op/cron.ts`, #2120) is
8
+ * deliberately more permissive: it also accepts the 6-field Quartz form
9
+ * (`second minute hour dom month dow`) that a `ConvergeOp`'s own `schedule`
10
+ * may legitimately carry, since the final word on cron syntax belongs to
11
+ * whichever scheduler runs the string. A k8s CronJob is that scheduler here,
12
+ * and it is stricter than core's parser, so a 6-field `ConvergeOp` schedule
13
+ * reused as-is on a CronJob fails at `kubectl apply`, not at build time.
14
+ *
15
+ * This module gives both composites one place to catch that at construction,
16
+ * reusing core's parser rather than a second copy of it.
17
+ */
18
+
19
+ import { isValidCronExpression } from "@intentius/chant/op";
20
+
21
+ /**
22
+ * Validate `schedule` for a k8s CronJob, throwing before a bad value ever
23
+ * reaches `kubectl apply`. `context` names the composite (and host, for
24
+ * `OperatorStack`) so the thrown message reads like the rest of that
25
+ * composite's build-time refusals, e.g. `OperatorStack "chant-operator",
26
+ * host "fountain-observe"` or `CronWorkload "backup"`.
27
+ */
28
+ export function validateCronJobSchedule(context: string, schedule: string): void {
29
+ if (!schedule || schedule.trim().length === 0) {
30
+ throw new Error(`${context}: schedule is required. A CronJob with no schedule never ticks.`);
31
+ }
32
+
33
+ const fields = schedule.trim().split(/\s+/);
34
+
35
+ if (!isValidCronExpression(schedule)) {
36
+ throw new Error(
37
+ `${context}: schedule "${schedule}" is not valid cron syntax. A Kubernetes CronJob takes five fields: minute hour day-of-month month day-of-week.`,
38
+ );
39
+ }
40
+
41
+ if (fields.length === 6) {
42
+ throw new Error(
43
+ `${context}: schedule "${schedule}" is 6-field cron. A Kubernetes CronJob takes five fields (minute hour day-of-month month day-of-week) and has no seconds column. ` +
44
+ `A ConvergeOp schedule string in that form must be converted before it reaches a CronJob: drop the leading seconds field and keep the remaining five.`,
45
+ );
46
+ }
47
+ }
@@ -8,13 +8,14 @@
8
8
  import { Composite, mergeDefaults } from "@intentius/chant";
9
9
  import { CronJob, ServiceAccount, Role, RoleBinding } from "../generated";
10
10
  import type { ContainerSecurityContext } from "./security-context";
11
+ import { validateCronJobSchedule } from "./cron-schedule";
11
12
 
12
13
  export interface CronWorkloadProps {
13
14
  /** Workload name — used in metadata and labels. */
14
15
  name: string;
15
16
  /** Container image. */
16
17
  image: string;
17
- /** Cron schedule expression (e.g., "0 * * * *"). */
18
+ /** Cron schedule expression (e.g., "0 * * * *"). Validated at construction: a k8s CronJob takes five fields only, so a 6-field (seconds) form is refused (`./cron-schedule.ts`, #2071). */
18
19
  schedule: string;
19
20
  /** Command to run in the container. */
20
21
  command?: string[];
@@ -92,6 +93,8 @@ export const CronWorkload = Composite((props: CronWorkloadProps) => {
92
93
  defaults: defs,
93
94
  } = props;
94
95
 
96
+ validateCronJobSchedule(`CronWorkload "${name}"`, schedule);
97
+
95
98
  const saName = `${name}-sa`;
96
99
  const roleName = `${name}-role`;
97
100
  const bindingName = `${name}-binding`;
@@ -76,6 +76,7 @@ import { Composite, mergeDefaults } from "@intentius/chant";
76
76
  import { classifyOpVerbClass } from "@intentius/chant/op";
77
77
  import type { OpConfig, OpVerbClass } from "@intentius/chant/op";
78
78
  import { Namespace, CronJob, ServiceAccount, Role, RoleBinding } from "../generated";
79
+ import { validateCronJobSchedule } from "./cron-schedule";
79
80
 
80
81
  // ── Types ────────────────────────────────────────────────────────────────
81
82
 
@@ -96,7 +97,7 @@ export interface OperatorRbacResourceRule {
96
97
  export interface OperatorStackConvergeHost {
97
98
  /** ConvergeOp's own name (`ConvergeOpConfig.name`) — the CronJob, ServiceAccount, Role, and RoleBinding name stem for this host. */
98
99
  name: string;
99
- /** Cron expression driving the tick — the same string passed to `ConvergeOp`'s own `schedule`. */
100
+ /** Cron expression driving the tick — the same string passed to `ConvergeOp`'s own `schedule`. Validated at construction: a 6-field (seconds) form, which `ConvergeOp` accepts, is refused here since a k8s CronJob takes five fields only (`./cron-schedule.ts`, #2071). */
100
101
  schedule: string;
101
102
  /** Environment this ConvergeOp converges (`ConvergeOpConfig.env`) — carried onto the container as `CHANT_CONVERGE_ENV`, for log/estate readability only (`chant run <name>` needs no `--env`: the target op already carries it). */
102
103
  env: string;
@@ -268,9 +269,7 @@ export const OperatorStack = Composite((props: OperatorStackConfig) => {
268
269
  throw new Error(`OperatorStack "${name}": duplicate hosted ConvergeOp name "${host.name}" — CronJob/ServiceAccount names would collide.`);
269
270
  }
270
271
  seen.add(host.name);
271
- if (!host.schedule || host.schedule.trim().length === 0) {
272
- throw new Error(`OperatorStack "${name}", host "${host.name}": schedule is required — an operator CronJob with no schedule never ticks.`);
273
- }
272
+ validateCronJobSchedule(`OperatorStack "${name}", host "${host.name}"`, host.schedule);
274
273
  }
275
274
  if (!image || image.trim().length === 0) {
276
275
  throw new Error(`OperatorStack "${name}": image is required — the CronJob has nothing to run.`);
@@ -28,6 +28,11 @@ export const k8sConfigSchema = z.strictObject({
28
28
  roots: z.array(z.string()).optional(),
29
29
  })
30
30
  .optional(),
31
+ receipts: z
32
+ .strictObject({
33
+ namespace: z.string().optional(),
34
+ })
35
+ .optional(),
31
36
  });
32
37
 
33
38
  declare module "@intentius/chant/config" {
package/src/config.ts CHANGED
@@ -111,6 +111,29 @@ export interface K8sChantConfig {
111
111
  /** Kustomization directories to render into the build. */
112
112
  roots?: string[];
113
113
  };
114
+
115
+ /**
116
+ * Effect receipt settings (#2074, epic #1703).
117
+ *
118
+ * `namespace` is where this project's receipt ConfigMaps live. The name is
119
+ * derived from the ownership fields (`chant-receipt.<stack>.<env>.<effect>`,
120
+ * see `./effect-receipt-row.ts`); the namespace is the one part of the
121
+ * address the ownership block cannot answer, so it is declared here. Unset,
122
+ * receipts land in `default`, the same namespace every other namespace-less
123
+ * k8s read and write in this lexicon falls through to. It is never derived
124
+ * from the stack or the environment: a guessed namespace is one chant would
125
+ * have to create, and the receipt row creates nothing but the receipt.
126
+ *
127
+ * ```ts
128
+ * k8s: {
129
+ * receipts: { namespace: "chant-system" },
130
+ * } satisfies K8sChantConfig
131
+ * ```
132
+ */
133
+ receipts?: {
134
+ /** Namespace the receipt ConfigMaps live in. Defaults to `default`. */
135
+ namespace?: string;
136
+ };
114
137
  }
115
138
 
116
139
  declare module "@intentius/chant/config" {
@@ -118,6 +118,7 @@ import {
118
118
  } from "./api/classify";
119
119
  import { operationFor } from "./api/operation-surface";
120
120
  import { k8sDeepNormalizationHooks } from "./deep-observe-hooks";
121
+ import { observeReceiptRowsDeep, receiptRowsFor } from "./receipt-store";
121
122
 
122
123
  // Re-exported so a dynamic importer of this module (plugin.ts's
123
124
  // `observeResourcesDeep`, a test) can get the reader and its hooks from one
@@ -159,11 +160,23 @@ export async function observeResourcesDeepK8s(
159
160
  ): Promise<DeepObservationResult> {
160
161
  const { managedFieldsOf, isChantFieldManager } = await import("@intentius/chant-k8s-client");
161
162
 
162
- const declared = [...options.entities].map(([entityName, entity]) => ({
163
- entityName,
164
- entityType: entity.entityType,
165
- props: entity.props,
166
- }));
163
+ // Effect receipts (#2074) are read by their own leg at the end: they carry
164
+ // no declared props, so every live path would be an unclaimed field (#2160)
165
+ // and their staleness is an `effect` row from the plan (#1832), never
166
+ // property drift. Reading them here keeps the deep read from calling a
167
+ // declared entity a hole; contributing an empty tree keeps it from calling
168
+ // one drift.
169
+ const receiptRows = receiptRowsFor(options.entityNames, options.buildOutput);
170
+
171
+ const declared = [...options.entities]
172
+ .filter(([entityName]) => !receiptRows.has(entityName))
173
+ .map(([entityName, entity]) => ({
174
+ entityName,
175
+ entityType: entity.entityType,
176
+ props: entity.props,
177
+ }));
178
+
179
+ const everyName = [...declared.map((d) => d.entityName), ...receiptRows.keys()];
167
180
 
168
181
  let client;
169
182
  try {
@@ -173,7 +186,7 @@ export async function observeResourcesDeepK8s(
173
186
  return deepObservation(
174
187
  {},
175
188
  unobservedAll(
176
- declared.map((d) => d.entityName),
189
+ everyName,
177
190
  "read-failed",
178
191
  MISSING_CLIENT_DETAIL,
179
192
  options.entities,
@@ -185,7 +198,7 @@ export async function observeResourcesDeepK8s(
185
198
  return deepObservation(
186
199
  {},
187
200
  unobservedAll(
188
- declared.map((d) => d.entityName),
201
+ everyName,
189
202
  outcome.kind === "unobserved" ? outcome.reason : "read-failed",
190
203
  outcome.kind === "unobserved" ? outcome.detail : undefined,
191
204
  options.entities,
@@ -267,5 +280,11 @@ export async function observeResourcesDeepK8s(
267
280
  }
268
281
  });
269
282
 
283
+ if (receiptRows.size > 0) {
284
+ const receiptObs = await observeReceiptRowsDeep(client, receiptRows);
285
+ Object.assign(resources, receiptObs.resources);
286
+ Object.assign(unobserved, receiptObs.unobserved);
287
+ }
288
+
270
289
  return deepObservation(resources, unobserved);
271
290
  }