@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
@@ -36,6 +36,7 @@ const { statusBody } = await import("@intentius/chant-k8s-client/testing");
36
36
  const { isChantFieldManager } = await import("@intentius/chant-k8s-client");
37
37
  const { diffDeepObservation, observeDeep } = await import("@intentius/chant/lifecycle/deep-observe");
38
38
  const { normalizeDeepObservation, normalizeDeepProperties } = await import("@intentius/chant/deep-observation");
39
+ const { heldElsewhere } = await import("@intentius/chant/held-elsewhere");
39
40
 
40
41
  type Entity = { name: string; entityType: string; props: Record<string, unknown> };
41
42
  function makeEntities(records: Entity[]): Map<string, { entityType: string; props: Record<string, unknown> }> {
@@ -687,19 +688,20 @@ describe("end to end: managed-fields-derived drift (#1076)", () => {
687
688
  },
688
689
  };
689
690
 
690
- test("genuine, undeclared and contested drift surface; accepted deviations and controller-stamped metadata do not", async () => {
691
+ test("contested drift surfaces; fields nobody declared are unclaimed and accepted deviations are held back", async () => {
691
692
  const live = normalizeDeepObservation(
692
693
  await observeResourcesDeepK8s({ environment: "prod", entityNames: [...declared.keys()], entities: declared }, cluster().connector),
693
694
  );
694
695
  const result = diffDeepObservation(declared, live, k8sDeepNormalizationHooks, baseline);
695
696
 
697
+ // Only a path chant's source declares can be drift (#2160). `tier` is
698
+ // declared and holds a different value live, which is the whole point of
699
+ // the contested case: a foreign write over a field chant asks for.
696
700
  expect(result.drifted).toEqual([
697
701
  {
698
702
  name: "web",
699
703
  type: "K8s::Apps::Deployment",
700
704
  changes: [
701
- // The #1191 case: foreign-owned, undeclared, not accepted — and the owner says who.
702
- { path: "metadata.labels.team", kind: "undeclared", live: "platform", owner: "kubectl-edit" },
703
705
  { path: "metadata.labels.tier", kind: "changed", declared: "backend", live: "frontend", owner: "chant:web" },
704
706
  ],
705
707
  },
@@ -710,16 +712,39 @@ describe("end to end: managed-fields-derived drift (#1076)", () => {
710
712
  },
711
713
  ]);
712
714
 
713
- // The accepted HPA replicas and sidecar are held back, not hidden.
715
+ // Everything source never set, with the manager where managedFields has one
716
+ // and the claim where it does not. The `team` label needs no baseline entry
717
+ // to stay quiet now, and `kubectl-edit` still says who put it there.
718
+ expect(result.unclaimed).toEqual([
719
+ {
720
+ name: "web",
721
+ type: "K8s::Apps::Deployment",
722
+ fields: [
723
+ { path: "metadata.labels.team", live: "platform", heldBy: "kubectl-edit", source: "field-manager" },
724
+ { path: "spec.replicas", live: 7, heldBy: "kube-controller-manager", source: "field-manager", baseline: 7 },
725
+ {
726
+ path: "spec.template.spec.containers[#istio-proxy].image",
727
+ live: "istio/proxyv2:1.20",
728
+ source: "claimed-fields",
729
+ baseline: "istio/proxyv2:1.20",
730
+ },
731
+ {
732
+ path: "spec.template.spec.containers[#istio-proxy].name",
733
+ live: "istio-proxy",
734
+ source: "claimed-fields",
735
+ baseline: "istio-proxy",
736
+ },
737
+ ],
738
+ },
739
+ ]);
740
+
741
+ // The accepted build-id is held back, not hidden.
714
742
  expect(result.accepted).toEqual([
715
743
  {
716
744
  name: "web",
717
745
  type: "K8s::Apps::Deployment",
718
746
  changes: [
719
747
  { path: "metadata.annotations.build-id", kind: "changed", declared: "42", live: "43", baseline: "43", owner: "chant:web" },
720
- { path: "spec.replicas", kind: "undeclared", live: 7, baseline: 7, owner: "kube-controller-manager" },
721
- { path: "spec.template.spec.containers[#istio-proxy].image", kind: "undeclared", live: "istio/proxyv2:1.20", baseline: "istio/proxyv2:1.20" },
722
- { path: "spec.template.spec.containers[#istio-proxy].name", kind: "undeclared", live: "istio-proxy", baseline: "istio-proxy" },
723
748
  ],
724
749
  },
725
750
  ]);
@@ -739,7 +764,10 @@ describe("end to end: managed-fields-derived drift (#1076)", () => {
739
764
  ]);
740
765
  });
741
766
 
742
- test("without the baseline every foreign-owned undeclared field is drift too; accepting is what silences it", async () => {
767
+ test("without the baseline only the declared paths are drift; the rest are still unclaimed", async () => {
768
+ // The baseline was the noise valve before #2160. It is no longer load
769
+ // bearing for a field nobody declared: the claim answers first, so an HPA's
770
+ // replica count and an injected sidecar are quiet with no baseline at all.
743
771
  const live = normalizeDeepObservation(
744
772
  await observeResourcesDeepK8s({ environment: "prod", entityNames: [...declared.keys()], entities: declared }, cluster().connector),
745
773
  );
@@ -747,8 +775,10 @@ describe("end to end: managed-fields-derived drift (#1076)", () => {
747
775
  const web = result.drifted.find((d) => d.name === "web");
748
776
  expect(web?.changes.map((c) => c.path).sort()).toEqual([
749
777
  "metadata.annotations.build-id",
750
- "metadata.labels.team",
751
778
  "metadata.labels.tier",
779
+ ]);
780
+ expect(result.unclaimed.find((d) => d.name === "web")?.fields.map((f) => f.path)).toEqual([
781
+ "metadata.labels.team",
752
782
  "spec.replicas",
753
783
  "spec.template.spec.containers[#istio-proxy].image",
754
784
  "spec.template.spec.containers[#istio-proxy].name",
@@ -756,38 +786,40 @@ describe("end to end: managed-fields-derived drift (#1076)", () => {
756
786
  expect(result.accepted).toEqual([]);
757
787
  });
758
788
 
759
- test("the same out-of-band label in the accepted baseline is not drift, at that value (#1191)", async () => {
789
+ test("managedFields names the manager where it has one, and the claim answers where it does not (#2160)", async () => {
790
+ // The k8s row is the only substrate where both sources are available, so
791
+ // it is the one place the fallback can be tested against real data. The
792
+ // `team` label has a managedFields entry addressed by a plain path, so the
793
+ // API server answers. The injected sidecar's fields are inside a list the
794
+ // diff addresses by key (`[#istio-proxy]`), which no managedFields entry
795
+ // names in that form, so the claimed-field set answers instead.
760
796
  const live = normalizeDeepObservation(
761
797
  await observeResourcesDeepK8s({ environment: "prod", entityNames: [...declared.keys()], entities: declared }, cluster().connector),
762
798
  );
763
- const withTeam = {
764
- web: { ...baseline.web, accepted: [...baseline.web.accepted, { path: "metadata.labels.team", value: "platform" }] },
765
- };
766
- const result = diffDeepObservation(declared, live, k8sDeepNormalizationHooks, withTeam);
767
- const web = result.drifted.find((d) => d.name === "web");
768
- expect(web?.changes).toEqual([
769
- { path: "metadata.labels.tier", kind: "changed", declared: "backend", live: "frontend", owner: "chant:web" },
770
- ]);
771
- expect(result.accepted.find((d) => d.name === "web")?.changes).toContainEqual({
799
+ const result = diffDeepObservation(declared, live, k8sDeepNormalizationHooks);
800
+ const web = result.unclaimed.find((d) => d.name === "web");
801
+
802
+ const byManager = web?.fields.find((f) => f.path === "metadata.labels.team");
803
+ expect(byManager).toEqual({
772
804
  path: "metadata.labels.team",
773
- kind: "undeclared",
774
805
  live: "platform",
775
- baseline: "platform",
776
- owner: "kubectl-edit",
806
+ heldBy: "kubectl-edit",
807
+ source: "field-manager",
777
808
  });
778
809
 
779
- // Value-bound: accepting `team=platform` does not bless `team=security`.
780
- const moved = {
781
- web: { ...baseline.web, accepted: [...baseline.web.accepted, { path: "metadata.labels.team", value: "security" }] },
782
- };
783
- const movedResult = diffDeepObservation(declared, live, k8sDeepNormalizationHooks, moved);
784
- expect(movedResult.drifted.find((d) => d.name === "web")?.changes).toContainEqual({
785
- path: "metadata.labels.team",
786
- kind: "undeclared",
787
- live: "platform",
788
- baseline: "security",
789
- owner: "kubectl-edit",
810
+ const byClaim = web?.fields.find((f) => f.path === "spec.template.spec.containers[#istio-proxy].image");
811
+ expect(byClaim).toEqual({
812
+ path: "spec.template.spec.containers[#istio-proxy].image",
813
+ live: "istio/proxyv2:1.20",
814
+ source: "claimed-fields",
790
815
  });
816
+ expect(byClaim).not.toHaveProperty("heldBy");
817
+
818
+ // Both sources agree on the verdict; they differ only in how much they can
819
+ // say about it. Neither is drift, and neither is proposed for update.
820
+ expect(result.drifted.find((d) => d.name === "web")?.changes.map((c) => c.path)).not.toContain(
821
+ "metadata.labels.team",
822
+ );
791
823
  });
792
824
 
793
825
  test("a whole-lexicon failure (no client) is a hole for every declared entity, never a clean report", async () => {
@@ -814,6 +846,142 @@ describe("end to end: managed-fields-derived drift (#1076)", () => {
814
846
  });
815
847
  });
816
848
 
849
+ /**
850
+ * #2162's honest example, driven through the real reader and core's real
851
+ * `diffDeepObservation` the same way the "end to end" suite above does — the
852
+ * declared alternative to the `accepted` baseline dance that suite's own
853
+ * `web` fixture needs for the very same field: an HPA owns `spec.replicas`
854
+ * on a Deployment source never sets a literal value for. Before this, the
855
+ * only way to stop hearing about it was `--update-baseline`, and a later
856
+ * scale event (7 -> 12) reported as drift again the moment the accepted
857
+ * value moved. `heldElsewhere()` needs no baseline entry at all, and does
858
+ * not re-alert when the HPA changes its mind.
859
+ */
860
+ describe("heldElsewhere(): the HPA/replicas case without a baseline entry (#2162)", () => {
861
+ const declared = makeEntities([
862
+ {
863
+ name: "web",
864
+ entityType: "K8s::Apps::Deployment",
865
+ props: {
866
+ metadata: { name: "web", namespace: "prod", labels: { app: "web" } },
867
+ spec: {
868
+ replicas: heldElsewhere<number>({ by: "hpa", reason: "the autoscaler owns replicas after the first apply" }),
869
+ selector: { matchLabels: { app: "web" } },
870
+ template: { metadata: { labels: { app: "web" } }, spec: { containers: [{ name: "app", image: "web:1.0" }] } },
871
+ },
872
+ },
873
+ },
874
+ ]);
875
+
876
+ const webLiveAt = (replicas: number) => ({
877
+ apiVersion: "apps/v1",
878
+ kind: "Deployment",
879
+ metadata: {
880
+ name: "web",
881
+ namespace: "prod",
882
+ uid: "uid-web",
883
+ labels: { app: "web" },
884
+ managedFields: [
885
+ {
886
+ manager: "chant:web",
887
+ operation: "Apply",
888
+ apiVersion: "apps/v1",
889
+ fieldsV1: {
890
+ "f:metadata": { "f:labels": { "f:app": {} } },
891
+ "f:spec": {
892
+ "f:selector": {},
893
+ "f:template": {
894
+ "f:spec": { "f:containers": { 'k:{"name":"app"}': { ".": {}, "f:name": {}, "f:image": {} } } },
895
+ },
896
+ },
897
+ },
898
+ },
899
+ {
900
+ // The autoscaler: chant's own manifest never mentions `replicas`,
901
+ // so nothing here contests it — the HPA is the only writer.
902
+ manager: "kube-controller-manager",
903
+ operation: "Update",
904
+ apiVersion: "apps/v1",
905
+ fieldsV1: { "f:spec": { "f:replicas": {} } },
906
+ },
907
+ ],
908
+ },
909
+ spec: {
910
+ replicas,
911
+ selector: { matchLabels: { app: "web" } },
912
+ template: { metadata: { labels: { app: "web" } }, spec: { containers: [{ name: "app", image: "web:1.0" }] } },
913
+ },
914
+ });
915
+
916
+ const clusterAt = (replicas: number) =>
917
+ fakeCluster({ objects: { [objectKey("apps/v1", "Deployment", "web", "prod")]: webLiveAt(replicas) } });
918
+
919
+ test("reports held with the HPA as owner — no drift, no baseline entry needed", async () => {
920
+ const live = normalizeDeepObservation(
921
+ await observeResourcesDeepK8s({ environment: "prod", entityNames: [...declared.keys()], entities: declared }, clusterAt(7).connector),
922
+ );
923
+ const result = diffDeepObservation(declared, live, k8sDeepNormalizationHooks);
924
+
925
+ expect(result.drifted).toEqual([]);
926
+ expect(result.accepted).toEqual([]);
927
+ expect(result.held).toEqual([
928
+ {
929
+ name: "web",
930
+ type: "K8s::Apps::Deployment",
931
+ held: [
932
+ {
933
+ path: "spec.replicas",
934
+ by: "hpa",
935
+ reason: "the autoscaler owns replicas after the first apply",
936
+ live: 7,
937
+ suspicious: false,
938
+ owner: "kube-controller-manager",
939
+ },
940
+ ],
941
+ },
942
+ ]);
943
+ });
944
+
945
+ test("a later scale event does not re-alert — unlike the accepted baseline, held is not value-bound", async () => {
946
+ const live = normalizeDeepObservation(
947
+ await observeResourcesDeepK8s({ environment: "prod", entityNames: [...declared.keys()], entities: declared }, clusterAt(12).connector),
948
+ );
949
+ const result = diffDeepObservation(declared, live, k8sDeepNormalizationHooks);
950
+
951
+ expect(result.drifted).toEqual([]);
952
+ expect(result.held[0].held[0]).toMatchObject({ live: 12, suspicious: false });
953
+ });
954
+
955
+ test("no live value at all is suspicious — the declared hand-over never showed up", async () => {
956
+ // No managedFields entry for replicas, and the live object carries none
957
+ // either — a Deployment where the HPA was never actually wired up.
958
+ const neverScaled = {
959
+ apiVersion: "apps/v1",
960
+ kind: "Deployment",
961
+ metadata: { name: "web", namespace: "prod", uid: "uid-web", labels: { app: "web" } },
962
+ spec: {
963
+ selector: { matchLabels: { app: "web" } },
964
+ template: { metadata: { labels: { app: "web" } }, spec: { containers: [{ name: "app", image: "web:1.0" }] } },
965
+ },
966
+ };
967
+ const live = normalizeDeepObservation(
968
+ await observeResourcesDeepK8s(
969
+ { environment: "prod", entityNames: [...declared.keys()], entities: declared },
970
+ fakeCluster({ objects: { [objectKey("apps/v1", "Deployment", "web", "prod")]: neverScaled } }).connector,
971
+ ),
972
+ );
973
+ const result = diffDeepObservation(declared, live, k8sDeepNormalizationHooks);
974
+
975
+ expect(result.held).toEqual([
976
+ {
977
+ name: "web",
978
+ type: "K8s::Apps::Deployment",
979
+ held: [{ path: "spec.replicas", by: "hpa", reason: "the autoscaler owns replicas after the first apply", suspicious: true }],
980
+ },
981
+ ]);
982
+ });
983
+ });
984
+
817
985
  /**
818
986
  * The managed-fields case named in #1076's acceptance, re-decided by #1191:
819
987
  * the same live mutation on the same field is `changed` when chant owns it
@@ -873,27 +1041,29 @@ describe("the managed-fields twist: ownership names the writer, the declared tre
873
1041
  ]);
874
1042
  });
875
1043
 
876
- test("a controller owns the field and source is silent: the same mutated value is undeclared drift until accepted (#1191)", async () => {
1044
+ test("a controller owns the field and source is silent: the same mutated value is unclaimed, never drift (#2160)", async () => {
1045
+ // The mirror of the test above, and the whole claim in one pair: the same
1046
+ // live value, the same manager, and the declaration is what decides
1047
+ // whether it is chant's problem.
877
1048
  const cluster = fakeCluster({ objects: { [objectKey("apps/v1", "Deployment", "app", "prod")]: liveWith("controller") } });
878
1049
  const entities = declaredWith(false);
879
1050
  const live = normalizeDeepObservation(
880
1051
  await observeResourcesDeepK8s({ environment: "prod", entityNames: ["app"], entities }, cluster.connector),
881
1052
  );
882
1053
  const result = diffDeepObservation(entities, live, k8sDeepNormalizationHooks);
883
- expect(result.drifted).toEqual([
1054
+ expect(result.drifted).toEqual([]);
1055
+ expect(result.unclaimed).toEqual([
884
1056
  {
885
1057
  name: "app",
886
1058
  type: "K8s::Apps::Deployment",
887
- changes: [{ path: "spec.template.spec.containers[#app].image", kind: "undeclared", live: "app:2.0" }],
1059
+ fields: [
1060
+ { path: "spec.template.spec.containers[#app].image", live: "app:2.0", source: "claimed-fields" },
1061
+ ],
888
1062
  },
889
1063
  ]);
890
-
891
- const accepted = {
892
- app: { accepted: [{ path: "spec.template.spec.containers[#app].image", value: "app:2.0" }] },
893
- };
894
- const quiet = diffDeepObservation(entities, live, k8sDeepNormalizationHooks, accepted);
895
- expect(quiet.drifted).toEqual([]);
896
- expect(quiet.accepted.map((d) => d.name)).toEqual(["app"]);
1064
+ // No baseline entry needed to silence it, which is what #1191's valve was
1065
+ // being used for.
1066
+ expect(result.unchanged).toEqual(["app"]);
897
1067
  });
898
1068
  });
899
1069
 
@@ -954,9 +1124,12 @@ describe("secret masking — diff --live never holds a Secret data value (#1830,
954
1124
  // MASTER_KEY is present on both sides: both values collapse to the mask,
955
1125
  // so a value-only difference is NOT drift — that is the contract, chant
956
1126
  // cannot know and must not learn whether the bytes moved.
957
- expect(changes.map((c) => `${c.path}:${c.kind}`).sort()).toEqual([
958
- "data.INJECTED_KEY:undeclared",
959
- "data.PENDING_KEY:absent",
1127
+ expect(changes.map((c) => `${c.path}:${c.kind}`).sort()).toEqual(["data.PENDING_KEY:absent"]);
1128
+
1129
+ // The injected key is a key source never declared, so it is unclaimed
1130
+ // (#2160). Presence still classifies; the value is still the mask.
1131
+ expect(result.unclaimed.find((d) => d.name === "master-key")?.fields).toEqual([
1132
+ { path: "data.INJECTED_KEY", live: "[REDACTED]", source: "claimed-fields" },
960
1133
  ]);
961
1134
 
962
1135
  // The hard line, asserted over the whole serialized result: no data
@@ -9,10 +9,10 @@
9
9
  *
10
10
  * ## What managedFields decides, and what it does not
11
11
  *
12
- * AWS, Azure and Temporal's rows all prune by a **static, entityType-keyed**
13
- * table: an ARN always looks like an ARN, `provisioningState` is always
14
- * server-populated, a namespace's retention default is always the same
15
- * value. None of that needs the specific live object in hand — it is exactly
12
+ * The AWS and Azure rows both prune by a **static, entityType-keyed** table
13
+ * (`AWS_SERVICE_DEFAULTS`, `AZURE_SERVICE_DEFAULTS`): an ARN always looks like
14
+ * an ARN, `provisioningState` is always server-populated. Neither needs the
15
+ * specific live object in hand — it is exactly
16
16
  * what `./deep-observe-hooks.ts`'s `k8sDeepNormalizationHooks` is, and it
17
17
  * covers Kubernetes' *equivalent* static noise (`status`,
18
18
  * `metadata.{uid,resourceVersion,generation,creationTimestamp}`, a handful of
@@ -31,7 +31,7 @@
31
31
  * was controller noise. It silenced exactly the case the deep read exists
32
32
  * for — `kubectl label deploy web team=platform` is foreign-owned
33
33
  * (`kubectl-edit`) and undeclared, and never appeared in fieldDrift. The
34
- * policy now (recorded on #1202) is:
34
+ * policy now (recorded on #1202, refined by #2160) is:
35
35
  *
36
36
  * 1. **Chant-owned** (`chant`, `chant:<stack>` — chant #1075, matched on the
37
37
  * family so a stack rename does not stop recognizing its own history) is
@@ -41,18 +41,25 @@
41
41
  * write currently holds the field, and a foreign write that overrides a
42
42
  * value chant's manifest asks for is the thing `lifecycle diff --live`
43
43
  * exists to surface. It reports as `changed`.
44
- * 3. **Foreign-owned and undeclared** is reported as `undeclared` drift,
45
- * unless the accepted baseline already carries it at that value. The
46
- * baseline (`--update-baseline`) is the noise valve: an HPA that owns
47
- * `spec.replicas` on a Deployment whose source is silent about replicas
48
- * reports once, gets accepted, and stays quiet until the value moves.
44
+ * 3. **Undeclared** a path outside the declaration's claimed-field set
45
+ * (`@intentius/chant/claimed-fields`) is reported as unclaimed and
46
+ * is not drift at all. Before #2160 it was `undeclared` drift that the
47
+ * accepted baseline had to absorb one path at a time; the declaration
48
+ * answers on the first read instead, so an HPA that owns `spec.replicas`
49
+ * on a Deployment whose source is silent about replicas is quiet with
50
+ * nothing recorded anywhere.
49
51
  *
50
- * So ownership decides nothing about *whether* a path is compared. What it
51
- * contributes is `fieldOwners` (#1189): every reported drift names the
52
- * manager that holds the field live, which is how an operator tells
53
- * `hpa-controller` doing its job from somebody running `kubectl edit`. The
54
- * only fields subtracted because a foreign manager wrote them are the
55
- * well-known system ones on the static allowlist.
52
+ * So ownership decides nothing about *whether* a path is compared, and since
53
+ * #2160 it no longer decides the verdict either the declaration does. What
54
+ * it contributes is `fieldOwners` (#1189), and that is worth more here than
55
+ * anywhere else: on every other substrate a held field can only be reported
56
+ * as "not chant's", while here it is reported as `hpa-controller`'s or
57
+ * `kubectl-edit`'s by name, which is the difference between a controller
58
+ * doing its job and somebody bypassing the pipeline. Core takes the manager
59
+ * where this reader supplies one and falls back to the claim where it does
60
+ * not (a path inside a key-addressed list, which no `fieldsV1` entry names in
61
+ * that form). The only fields subtracted because a foreign manager wrote them
62
+ * are the well-known system ones on the static allowlist.
56
63
  *
57
64
  * ## Resolving a managedFields entry against a live array
58
65
  *
@@ -111,10 +118,11 @@ import {
111
118
  } from "./api/classify";
112
119
  import { operationFor } from "./api/operation-surface";
113
120
  import { k8sDeepNormalizationHooks } from "./deep-observe-hooks";
121
+ import { observeReceiptRowsDeep, receiptRowsFor } from "./receipt-store";
114
122
 
115
123
  // Re-exported so a dynamic importer of this module (plugin.ts's
116
124
  // `observeResourcesDeep`, a test) can get the reader and its hooks from one
117
- // place, the same shape AWS/Azure/Temporal's single deep-observe.ts offers.
125
+ // place, the same shape the AWS and Azure deep-observe.ts modules offer.
118
126
  // `plugin.ts`'s own `deepNormalizationHooks` field imports the hooks
119
127
  // separately, directly from `./deep-observe-hooks` — that file has no
120
128
  // dependency on `@intentius/chant-k8s-client`, so it is safe to import
@@ -152,11 +160,23 @@ export async function observeResourcesDeepK8s(
152
160
  ): Promise<DeepObservationResult> {
153
161
  const { managedFieldsOf, isChantFieldManager } = await import("@intentius/chant-k8s-client");
154
162
 
155
- const declared = [...options.entities].map(([entityName, entity]) => ({
156
- entityName,
157
- entityType: entity.entityType,
158
- props: entity.props,
159
- }));
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()];
160
180
 
161
181
  let client;
162
182
  try {
@@ -166,7 +186,7 @@ export async function observeResourcesDeepK8s(
166
186
  return deepObservation(
167
187
  {},
168
188
  unobservedAll(
169
- declared.map((d) => d.entityName),
189
+ everyName,
170
190
  "read-failed",
171
191
  MISSING_CLIENT_DETAIL,
172
192
  options.entities,
@@ -178,7 +198,7 @@ export async function observeResourcesDeepK8s(
178
198
  return deepObservation(
179
199
  {},
180
200
  unobservedAll(
181
- declared.map((d) => d.entityName),
201
+ everyName,
182
202
  outcome.kind === "unobserved" ? outcome.reason : "read-failed",
183
203
  outcome.kind === "unobserved" ? outcome.detail : undefined,
184
204
  options.entities,
@@ -244,10 +264,10 @@ export async function observeResourcesDeepK8s(
244
264
  hooks: k8sDeepNormalizationHooks,
245
265
  }),
246
266
  // Who owns each path (#1189). Nothing is pruned by ownership any more
247
- // (#1191), so a foreign-owned undeclared field reaches the diff as
248
- // `undeclared` — and naming the manager is the whole question there:
249
- // an operator needs to tell `hpa-controller` doing its job from
250
- // somebody running `kubectl edit`.
267
+ // (#1191), so a foreign-owned undeclared field still reaches the diff
268
+ // as a held field since #2160 — and naming the manager is the whole
269
+ // question there: an operator needs to tell `hpa-controller` doing its
270
+ // job from somebody running `kubectl edit`.
251
271
  ...(sets.owners.size > 0 ? { fieldOwners: Object.fromEntries(sets.owners) } : {}),
252
272
  };
253
273
  } catch (err) {
@@ -260,5 +280,11 @@ export async function observeResourcesDeepK8s(
260
280
  }
261
281
  });
262
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
+
263
289
  return deepObservation(resources, unobserved);
264
290
  }
@@ -68,6 +68,7 @@ import {
68
68
  import { operationFor } from "./api/operation-surface";
69
69
  import { resolveK8sOwnerChain } from "./api/owner-chain";
70
70
  import { gvkToTypeName } from "./spec/parse";
71
+ import { observeReceiptRows, receiptRowsFor } from "./receipt-store";
71
72
 
72
73
  function pruneUndefined<T extends Record<string, unknown>>(obj: T): Record<string, unknown> {
73
74
  const out: Record<string, unknown> = {};
@@ -357,11 +358,26 @@ export async function describeResources(
357
358
  // looked for where it lives".
358
359
  const queried: Record<string, string> = {};
359
360
 
360
- const declared: Declared[] = [...options.entities].map(([entityName, entity]) => ({
361
- entityName,
362
- entityType: entity.entityType,
363
- props: entity.props,
364
- }));
361
+ // Effect receipt rows (#2074) are read by their own leg below: the applier
362
+ // never wrote them (#1832), and their declaration carries no props, so the
363
+ // generic sweep has no `metadata.name` to query by and would report a hole
364
+ // where the receipt leg has a real answer. Their addresses come from the
365
+ // build output's receipt comment, which is the serializer's one rendering of
366
+ // the derivation (./effect-receipt-row.ts).
367
+ const receiptRows = receiptRowsFor(options.entityNames, options.buildOutput);
368
+
369
+ const declared: Declared[] = [...options.entities]
370
+ .filter(([entityName]) => !receiptRows.has(entityName))
371
+ .map(([entityName, entity]) => ({
372
+ entityName,
373
+ entityType: entity.entityType,
374
+ props: entity.props,
375
+ }));
376
+
377
+ // A whole-lexicon failure below is a hole for the receipts too: nobody
378
+ // looked at those either, and a connect that never happened proves nothing
379
+ // about a receipt's presence.
380
+ const everyName = [...declared.map((d) => d.entityName), ...receiptRows.keys()];
365
381
 
366
382
  // Connect first. The binding check lives here, so a bound-but-mismatched
367
383
  // context throws before any resource is read — core turns that into
@@ -374,7 +390,7 @@ export async function describeResources(
374
390
  return observation(
375
391
  {},
376
392
  unobservedAll(
377
- declared.map((d) => d.entityName),
393
+ everyName,
378
394
  "read-failed",
379
395
  MISSING_CLIENT_DETAIL,
380
396
  options.entities,
@@ -386,7 +402,7 @@ export async function describeResources(
386
402
  return observation(
387
403
  {},
388
404
  unobservedAll(
389
- declared.map((d) => d.entityName),
405
+ everyName,
390
406
  outcome.kind === "unobserved" ? outcome.reason : "read-failed",
391
407
  outcome.kind === "unobserved" ? outcome.detail : undefined,
392
408
  options.entities,
@@ -507,6 +523,14 @@ export async function describeResources(
507
523
 
508
524
  await addRuntimeChildren(client, resources, unobserved, options.owned, declared);
509
525
 
526
+ // The receipt leg last, so its answers are the ones that stand for the
527
+ // receipt entities, because nothing above ever looked at one.
528
+ if (receiptRows.size > 0) {
529
+ const receiptObs = await observeReceiptRows(client, receiptRows);
530
+ Object.assign(resources, receiptObs.resources);
531
+ Object.assign(unobserved, receiptObs.unobserved);
532
+ }
533
+
510
534
  return observation(resources, unobserved, queried);
511
535
  }
512
536