@intentius/chant-lexicon-k8s 0.44.13 → 0.45.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 (56) hide show
  1. package/dist/codegen/docs.d.ts.map +1 -1
  2. package/dist/codegen/generate-lexicon.d.ts +15 -1
  3. package/dist/codegen/generate-lexicon.d.ts.map +1 -1
  4. package/dist/components/capability-plugin.d.ts.map +1 -1
  5. package/dist/crd/parser.d.ts +28 -1
  6. package/dist/crd/parser.d.ts.map +1 -1
  7. package/dist/deep-observe-hooks.d.ts +60 -4
  8. package/dist/deep-observe-hooks.d.ts.map +1 -1
  9. package/dist/deep-observe.d.ts +36 -61
  10. package/dist/deep-observe.d.ts.map +1 -1
  11. package/dist/integrity.json +6 -3
  12. package/dist/lint/audit-catalog.d.ts.map +1 -1
  13. package/dist/lint/post-synth/crd-schema-helpers.d.ts +49 -0
  14. package/dist/lint/post-synth/crd-schema-helpers.d.ts.map +1 -0
  15. package/dist/lint/post-synth/index.d.ts.map +1 -1
  16. package/dist/lint/post-synth/wk8501.d.ts +14 -0
  17. package/dist/lint/post-synth/wk8501.d.ts.map +1 -0
  18. package/dist/lint/post-synth/wk8502.d.ts +13 -0
  19. package/dist/lint/post-synth/wk8502.d.ts.map +1 -0
  20. package/dist/manifest.json +1 -1
  21. package/dist/meta.json +126 -63
  22. package/dist/okf/index.md +2 -0
  23. package/dist/okf/rules/WK8501.md +11 -0
  24. package/dist/okf/rules/WK8502.md +11 -0
  25. package/dist/rules/crd-schema-helpers.ts +228 -0
  26. package/dist/rules/wk8501.ts +40 -0
  27. package/dist/rules/wk8502.ts +39 -0
  28. package/dist/spec/parse.d.ts +32 -0
  29. package/dist/spec/parse.d.ts.map +1 -1
  30. package/dist/validate.d.ts.map +1 -1
  31. package/package.json +3 -3
  32. package/src/codegen/docs.ts +0 -1044
  33. package/src/codegen/generate-lexicon.ts +38 -2
  34. package/src/codegen/snapshot.test.ts +20 -0
  35. package/src/components/capability-plugin.test.ts +20 -0
  36. package/src/components/capability-plugin.ts +5 -2
  37. package/src/crd/cnpg.test.ts +1 -1
  38. package/src/crd/infisical.test.ts +1 -1
  39. package/src/crd/parser.test.ts +117 -1
  40. package/src/crd/parser.ts +71 -5
  41. package/src/crd/traefik.test.ts +1 -1
  42. package/src/deep-observe-hooks.ts +78 -16
  43. package/src/deep-observe.test.ts +145 -29
  44. package/src/deep-observe.ts +43 -85
  45. package/src/generated/lexicon-k8s.json +126 -63
  46. package/src/lint/audit-catalog.ts +2 -0
  47. package/src/lint/post-synth/crd-schema-helpers.ts +228 -0
  48. package/src/lint/post-synth/index.ts +4 -0
  49. package/src/lint/post-synth/post-synth.test.ts +156 -0
  50. package/src/lint/post-synth/wk8501.ts +40 -0
  51. package/src/lint/post-synth/wk8502.ts +39 -0
  52. package/src/list-map-key-table.test.ts +83 -0
  53. package/src/op/activities/kubectl.ts +2 -2
  54. package/src/serializer.test.ts +4 -4
  55. package/src/spec/parse.ts +33 -0
  56. package/src/validate.ts +7 -4
@@ -128,6 +128,38 @@ describe("k8sDeepNormalizationHooks — the static rules", () => {
128
128
  expect(foreign).toEqual({ metadata: { name: "web", labels: { "app.kubernetes.io/managed-by": "helm" } } });
129
129
  });
130
130
 
131
+ test("controller-stamped annotations and labels subtract; an out-of-band user label does not (#1191)", () => {
132
+ const live = {
133
+ metadata: {
134
+ name: "web",
135
+ labels: { "pod-template-hash": "5d4f8b7c9", team: "platform" },
136
+ annotations: {
137
+ "deployment.kubernetes.io/revision": "3",
138
+ "kubectl.kubernetes.io/last-applied-configuration": "{}",
139
+ "kubernetes.io/change-cause": "kubectl set image",
140
+ },
141
+ },
142
+ };
143
+ const out = normalizeDeepProperties(live, {
144
+ entityType: "K8s::Apps::Deployment",
145
+ side: "live",
146
+ hooks: k8sDeepNormalizationHooks,
147
+ counterpartPaths: new Set(["metadata", "metadata.name"]),
148
+ });
149
+ expect(out).toEqual({
150
+ metadata: { name: "web", labels: { team: "platform" }, annotations: { "kubernetes.io/change-cause": "kubectl set image" } },
151
+ });
152
+
153
+ // Declared, so compared — the allowlist is counterpart-gated like the defaults table.
154
+ const declaredRevision = normalizeDeepProperties(live, {
155
+ entityType: "K8s::Apps::Deployment",
156
+ side: "live",
157
+ hooks: k8sDeepNormalizationHooks,
158
+ counterpartPaths: new Set(["metadata", "metadata.name", "metadata.annotations", "metadata.annotations.deployment.kubernetes.io/revision"]),
159
+ });
160
+ expect((declaredRevision as { metadata: { annotations: Record<string, string> } }).metadata.annotations["deployment.kubernetes.io/revision"]).toBe("3");
161
+ });
162
+
131
163
  test("a server-assigned ClusterIP nobody declared subtracts; a declared one is compared", () => {
132
164
  const undeclared = normalizeDeepProperties(
133
165
  { spec: { clusterIP: "10.43.201.161", clusterIPs: ["10.43.201.161"], ipFamilies: ["IPv4"], selector: { app: "web" } } },
@@ -453,8 +485,10 @@ describe("observeResourcesDeepK8s — reading through the typed client (#1076)",
453
485
  */
454
486
  describe("end to end: managed-fields-derived drift (#1076)", () => {
455
487
  const declared = makeEntities([
456
- // "web": HPA owns replicas (undeclared pruned), Istio injects a sidecar
457
- // (undeclared pruned), and someone changed a label chant owns.
488
+ // "web": someone changed a label chant owns, someone added a label chant
489
+ // never declared (#1191), HPA owns replicas and Istio injected a sidecar
490
+ // (both undeclared — drift until accepted), and the Deployment controller
491
+ // stamped its revision annotation (system noise, never drift).
458
492
  {
459
493
  name: "web",
460
494
  entityType: "K8s::Apps::Deployment",
@@ -505,10 +539,12 @@ describe("end to end: managed-fields-derived drift (#1076)", () => {
505
539
  resourceVersion: "42",
506
540
  generation: 7,
507
541
  creationTimestamp: "2026-01-01T00:00:00Z",
508
- // GENUINE DRIFT: chant owns this label, and the live value moved.
509
- labels: { app: "web", tier: "frontend" },
510
- // ACCEPTED: chant owns this annotation; the platform's baseline accepts "43".
511
- annotations: { "build-id": "43" },
542
+ // GENUINE DRIFT: chant owns `tier`, and the live value moved.
543
+ // UNDECLARED DRIFT: `team` was added out of band by `kubectl label` (#1191).
544
+ labels: { app: "web", tier: "frontend", team: "platform" },
545
+ // ACCEPTED: chant owns build-id; the platform's baseline accepts "43".
546
+ // NOISE: the Deployment controller's revision counter is on the static allowlist.
547
+ annotations: { "build-id": "43", "deployment.kubernetes.io/revision": "3" },
512
548
  managedFields: [
513
549
  {
514
550
  manager: "chant:web",
@@ -529,15 +565,27 @@ describe("end to end: managed-fields-derived drift (#1076)", () => {
529
565
  },
530
566
  },
531
567
  {
532
- // NOISE: HPA owns replicas, and chant never declared it.
568
+ // UNDECLARED: a human ran `kubectl label deploy web team=platform`.
569
+ manager: "kubectl-edit",
570
+ operation: "Update",
571
+ apiVersion: "apps/v1",
572
+ time: "2026-01-03T00:00:00Z",
573
+ fieldsV1: { "f:metadata": { "f:labels": { "f:team": {} } } },
574
+ },
575
+ {
576
+ // UNDECLARED until accepted: HPA owns replicas, and chant never declared it.
577
+ // SYSTEM NOISE: the revision annotation, stamped by the Deployment controller.
533
578
  manager: "kube-controller-manager",
534
579
  operation: "Update",
535
580
  apiVersion: "apps/v1",
536
581
  time: "2026-01-02T00:00:00Z",
537
- fieldsV1: { "f:spec": { "f:replicas": {} } },
582
+ fieldsV1: {
583
+ "f:metadata": { "f:annotations": { "f:deployment.kubernetes.io/revision": {} } },
584
+ "f:spec": { "f:replicas": {} },
585
+ },
538
586
  },
539
587
  {
540
- // NOISE: a mutating webhook injected a whole sidecar container.
588
+ // UNDECLARED until accepted: a mutating webhook injected a whole sidecar container.
541
589
  manager: "istio-sidecar-injector",
542
590
  operation: "Update",
543
591
  apiVersion: "apps/v1",
@@ -625,14 +673,21 @@ describe("end to end: managed-fields-derived drift (#1076)", () => {
625
673
  respond: (req) => (req.path.endsWith("/deployments/broken") ? { status: 500, body: statusBody(500, "InternalError", "backend unavailable") } : undefined),
626
674
  });
627
675
 
676
+ // The platform accepted the HPA and the sidecar — the valve #1191 leans on —
677
+ // but nobody accepted the `team` label.
628
678
  const baseline = {
629
679
  web: {
630
680
  type: "K8s::Apps::Deployment",
631
- accepted: [{ path: "metadata.annotations.build-id", value: "43" }],
681
+ accepted: [
682
+ { path: "metadata.annotations.build-id", value: "43" },
683
+ { path: "spec.replicas", value: 7 },
684
+ { path: "spec.template.spec.containers[#istio-proxy].name", value: "istio-proxy" },
685
+ { path: "spec.template.spec.containers[#istio-proxy].image", value: "istio/proxyv2:1.20" },
686
+ ],
632
687
  },
633
688
  };
634
689
 
635
- test("exactly the genuine + contested drift surfaces; controller noise and the accepted annotation do not", async () => {
690
+ test("genuine, undeclared and contested drift surface; accepted deviations and controller-stamped metadata do not", async () => {
636
691
  const live = normalizeDeepObservation(
637
692
  await observeResourcesDeepK8s({ environment: "prod", entityNames: [...declared.keys()], entities: declared }, cluster().connector),
638
693
  );
@@ -642,28 +697,36 @@ describe("end to end: managed-fields-derived drift (#1076)", () => {
642
697
  {
643
698
  name: "web",
644
699
  type: "K8s::Apps::Deployment",
645
- changes: [{ path: "metadata.labels.tier", kind: "changed", declared: "backend", live: "frontend" }],
700
+ 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
+ { path: "metadata.labels.tier", kind: "changed", declared: "backend", live: "frontend", owner: "chant:web" },
704
+ ],
646
705
  },
647
706
  {
648
707
  name: "worker",
649
708
  type: "K8s::Apps::Deployment",
650
- changes: [{ path: "spec.replicas", kind: "changed", declared: 5, live: 9 }],
709
+ changes: [{ path: "spec.replicas", kind: "changed", declared: 5, live: 9, owner: "kubectl-client-side-apply" }],
651
710
  },
652
711
  ]);
653
712
 
713
+ // The accepted HPA replicas and sidecar are held back, not hidden.
654
714
  expect(result.accepted).toEqual([
655
715
  {
656
716
  name: "web",
657
717
  type: "K8s::Apps::Deployment",
658
- changes: [{ path: "metadata.annotations.build-id", kind: "changed", declared: "42", live: "43", baseline: "43" }],
718
+ changes: [
719
+ { 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
+ ],
659
724
  },
660
725
  ]);
661
726
 
662
- // The HPA-owned replicas and the Istio sidecar never appear at all — not
663
- // as drift, not as "undeclared" noise. Pruned before the diff ever runs.
664
- const webDriftPaths = result.drifted.find((d) => d.name === "web")?.changes.map((c) => c.path) ?? [];
665
- expect(webDriftPaths).not.toContain("spec.replicas");
666
- expect(JSON.stringify(result)).not.toContain("istio-proxy");
727
+ // The Deployment controller's revision counter never appears at all — not
728
+ // as drift, not as accepted. Subtracted by the static allowlist.
729
+ expect(JSON.stringify(result)).not.toContain("deployment.kubernetes.io/revision");
667
730
 
668
731
  expect(result.unobserved).toEqual([
669
732
  { name: "broken", type: "K8s::Apps::Deployment", reason: "read-failed", detail: expect.stringContaining("500") },
@@ -676,16 +739,57 @@ describe("end to end: managed-fields-derived drift (#1076)", () => {
676
739
  ]);
677
740
  });
678
741
 
679
- test("without the baseline the annotation is drift too; accepting it is what silences it", async () => {
742
+ test("without the baseline every foreign-owned undeclared field is drift too; accepting is what silences it", async () => {
680
743
  const live = normalizeDeepObservation(
681
744
  await observeResourcesDeepK8s({ environment: "prod", entityNames: [...declared.keys()], entities: declared }, cluster().connector),
682
745
  );
683
746
  const result = diffDeepObservation(declared, live, k8sDeepNormalizationHooks);
684
747
  const web = result.drifted.find((d) => d.name === "web");
685
- expect(web?.changes.map((c) => c.path).sort()).toEqual(["metadata.annotations.build-id", "metadata.labels.tier"]);
748
+ expect(web?.changes.map((c) => c.path).sort()).toEqual([
749
+ "metadata.annotations.build-id",
750
+ "metadata.labels.team",
751
+ "metadata.labels.tier",
752
+ "spec.replicas",
753
+ "spec.template.spec.containers[#istio-proxy].image",
754
+ "spec.template.spec.containers[#istio-proxy].name",
755
+ ]);
686
756
  expect(result.accepted).toEqual([]);
687
757
  });
688
758
 
759
+ test("the same out-of-band label in the accepted baseline is not drift, at that value (#1191)", async () => {
760
+ const live = normalizeDeepObservation(
761
+ await observeResourcesDeepK8s({ environment: "prod", entityNames: [...declared.keys()], entities: declared }, cluster().connector),
762
+ );
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({
772
+ path: "metadata.labels.team",
773
+ kind: "undeclared",
774
+ live: "platform",
775
+ baseline: "platform",
776
+ owner: "kubectl-edit",
777
+ });
778
+
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",
790
+ });
791
+ });
792
+
689
793
  test("a whole-lexicon failure (no client) is a hole for every declared entity, never a clean report", async () => {
690
794
  // The reader's contract (matching describe-resources.ts) is to *throw* for
691
795
  // an unrecognized connect failure, not to catch it — core's own
@@ -711,11 +815,12 @@ describe("end to end: managed-fields-derived drift (#1076)", () => {
711
815
  });
712
816
 
713
817
  /**
714
- * The managed-fields-specific case named in #1076's acceptance: the same
715
- * live mutation on the same field is drift when chant owns it, and silence
716
- * when a controller owns it and source is silent.
818
+ * The managed-fields case named in #1076's acceptance, re-decided by #1191:
819
+ * the same live mutation on the same field is `changed` when chant owns it
820
+ * and source declares it, and `undeclared` when a controller owns it and
821
+ * source is silent. Ownership names the writer; it no longer silences.
717
822
  */
718
- describe("the managed-fields twist: ownership decides drift vs silence for the same mutation (#1076)", () => {
823
+ describe("the managed-fields twist: ownership names the writer, the declared tree decides the kind (#1076, #1191)", () => {
719
824
  const liveWith = (owner: "chant" | "controller") => ({
720
825
  apiVersion: "apps/v1",
721
826
  kind: "Deployment",
@@ -768,15 +873,26 @@ describe("the managed-fields twist: ownership decides drift vs silence for the s
768
873
  ]);
769
874
  });
770
875
 
771
- test("a controller owns the field and source is silent: the same mutated value is silence, not drift and not undeclared noise", async () => {
876
+ test("a controller owns the field and source is silent: the same mutated value is undeclared drift until accepted (#1191)", async () => {
772
877
  const cluster = fakeCluster({ objects: { [objectKey("apps/v1", "Deployment", "app", "prod")]: liveWith("controller") } });
773
878
  const entities = declaredWith(false);
774
879
  const live = normalizeDeepObservation(
775
880
  await observeResourcesDeepK8s({ environment: "prod", entityNames: ["app"], entities }, cluster.connector),
776
881
  );
777
882
  const result = diffDeepObservation(entities, live, k8sDeepNormalizationHooks);
778
- expect(result.drifted).toEqual([]);
779
- expect(result.unchanged).toEqual(["app"]);
780
- expect(JSON.stringify(result)).not.toContain("app:2.0");
883
+ expect(result.drifted).toEqual([
884
+ {
885
+ name: "app",
886
+ type: "K8s::Apps::Deployment",
887
+ changes: [{ path: "spec.template.spec.containers[#app].image", kind: "undeclared", live: "app:2.0" }],
888
+ },
889
+ ]);
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"]);
781
897
  });
782
898
  });
@@ -7,8 +7,7 @@
7
7
  * rather than the hand-maintained `ignoreDifferences` list every Argo user
8
8
  * keeps.
9
9
  *
10
- * ## Why this is not "the AWS reader, with `kubectl get` instead of Cloud
11
- * Control"
10
+ * ## What managedFields decides, and what it does not
12
11
  *
13
12
  * AWS, Azure and Temporal's rows all prune by a **static, entityType-keyed**
14
13
  * table: an ARN always looks like an ARN, `provisioningState` is always
@@ -17,65 +16,43 @@
17
16
  * what `./deep-observe-hooks.ts`'s `k8sDeepNormalizationHooks` is, and it
18
17
  * covers Kubernetes' *equivalent* static noise (`status`,
19
18
  * `metadata.{uid,resourceVersion,generation,creationTimestamp}`, a handful of
20
- * server-defaulted pod-spec fields).
19
+ * server-defaulted pod-spec fields, and the annotations and labels
20
+ * Kubernetes' own controllers stamp on every object of a kind —
21
+ * `K8S_SYSTEM_METADATA_PRUNE_PATTERNS` in `@intentius/chant/managed-fields`).
21
22
  *
22
- * managedFields is not that kind of rule. Whether `spec.replicas` is noise on
23
- * *this* Deployment depends on whether *this* Deployment's
24
- * `metadata.managedFields` says a controller owns it a fact that differs
25
- * between two Deployments of the same type, and that the declared tree never
26
- * carries at all (chant's source has no `managedFields` key to normalize).
27
- * `DeepNormalizationHooks.prune` cannot see the object it is walking, only a
28
- * path and a value, so this cannot be expressed as a fixed hook the way the
29
- * other three rows' entire contribution is. It has to be computed once per
30
- * resource, here, and layered on top of the static rules before the tree is
31
- * normalized.
23
+ * managedFields is a different kind of fact. Which manager owns
24
+ * `spec.replicas` on *this* Deployment differs between two Deployments of
25
+ * the same type, and the declared tree never carries it at all (chant's
26
+ * source has no `managedFields` key to normalize). It has to be resolved once
27
+ * per resource, here, against the object in hand.
32
28
  *
33
- * ## The contested-field rule, precisely
29
+ * Until chant #1191 that resolution *pruned*: a foreign-owned path nobody
30
+ * declared was dropped before the tree reached core, on the theory that it
31
+ * was controller noise. It silenced exactly the case the deep read exists
32
+ * for — `kubectl label deploy web team=platform` is foreign-owned
33
+ * (`kubectl-edit`) and undeclared, and never appeared in fieldDrift. The
34
+ * policy now (recorded on #1202) is:
34
35
  *
35
- * For every path on the live tree, three questions, each independent:
36
+ * 1. **Chant-owned** (`chant`, `chant:<stack>` chant #1075, matched on the
37
+ * family so a stack rename does not stop recognizing its own history) is
38
+ * always diffable.
39
+ * 2. **Declared, whoever owns it live** (a contested field) is always
40
+ * diffable: chant's source is a statement of intent independent of which
41
+ * write currently holds the field, and a foreign write that overrides a
42
+ * value chant's manifest asks for is the thing `lifecycle diff --live`
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.
36
49
  *
37
- * 1. **Does any chant field manager (`chant`, `chant:<stack>` chant #1075,
38
- * matched on the family so a stack rename does not stop recognizing its
39
- * own history) own this path?** If so it is chant's business regardless of
40
- * what else is true always diffable, never pruned by this rule.
41
- * 2. **Does some *other* manager own this path, and chant does not?** That is
42
- * controller-managed noise HPA rewriting `spec.replicas`, a mutating
43
- * webhook defaulting a field, `kubectl-client-side-apply` from a human
44
- * operator — *provided* nobody declared it (next question). Prune it.
45
- * 3. **Does chant's own manifest *also* set this path?** — independent of
46
- * who currently owns it live. This is the case the issue calls out by
47
- * name: **a contested field chant declared is drift-relevant.** The
48
- * reasoning: chant's source is a statement of intent regardless of
49
- * whether a previous apply is what currently holds the field, and a
50
- * foreign write that overrides a value chant's manifest asks for is
51
- * exactly the thing `lifecycle diff --live` exists to surface — silencing
52
- * it because *something else* currently owns the field would hide the one
53
- * case where chant and the cluster disagree about a property chant is
54
- * actively declaring. So: foreign-owned *and* declared is never pruned by
55
- * this rule, whatever #2 would otherwise do.
56
- *
57
- * `counterpart: "unknown"` and why it does not help here: core's own
58
- * counterpart tri-state (`side === "live" && counterpart === "absent"`, what
59
- * {@link K8S_SERVICE_DEFAULTS} is gated on) answers "did the *declared tree*
60
- * carry this path at all" — which is exactly question 3, but it is computed
61
- * by *core*, from the raw declared and raw live trees, only on the **second**
62
- * normalization pass (`packages/core/src/lifecycle/deep-observe.ts`'s
63
- * `diffDeepObservation`). This reader's own call to
64
- * {@link normalizeDeepProperties} runs first, with no `counterpartPaths`
65
- * supplied — same as the other three rows — so `counterpart` is `"unknown"`
66
- * for every node here, and a rule gated on `"absent"` would never fire during
67
- * this read at all. That is fine for a table keyed only by entityType (AWS's
68
- * service defaults are subtracted on core's later pass, not this one), but it
69
- * is *not* fine for the managed-fields rule: if this reader left
70
- * `spec.replicas` in the tree waiting for core's second pass to prune it,
71
- * core would be normalizing with the *static* `k8sDeepNormalizationHooks`,
72
- * which has no managedFields for this object in hand either (this reader
73
- * already computed its ownership sets from data it read once and does not
74
- * return). So question 3 is answered **here**, directly against the declared
75
- * `props` this reader was handed — not via `counterpart` — and the result is
76
- * baked into what this reader returns: a foreign-owned, undeclared field is
77
- * gone from the tree before it ever reaches core, and a contested one is
78
- * still there for the ordinary declared-vs-live comparison to catch.
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.
79
56
  *
80
57
  * ## Resolving a managedFields entry against a live array
81
58
  *
@@ -99,10 +76,8 @@
99
76
  * container that also sets no scalar sub-fields (unusual — a real apply
100
77
  * setting a container almost always sets `image`/`name` too) would not confer
101
78
  * ownership onto a sibling field nobody's `fieldsV1` entry lists explicitly.
102
- * That is the conservative direction: a path absent from every manager's
103
- * fields is treated as "no ownership information", which leaves it diffable
104
- * rather than silently pruned. Reported drift that turns out to be more
105
- * noise than expected is a tuning problem; drift silently dropped is not.
79
+ * A path absent from every manager's fields simply has no `owner` on its
80
+ * drift line; it is compared like any other.
106
81
  *
107
82
  * ## `buildOwnershipSets` lives in core now
108
83
  *
@@ -119,7 +94,6 @@
119
94
 
120
95
  import type { K8sObject } from "@intentius/chant-k8s-client";
121
96
  import type {
122
- DeepNormalizationHooks,
123
97
  DeepObservationResult,
124
98
  DeepResourceObservation,
125
99
  UnobservedEntity,
@@ -127,7 +101,7 @@ import type {
127
101
  import { deepObservation, normalizeDeepProperties } from "@intentius/chant/deep-observation";
128
102
  import { unobservedAll } from "@intentius/chant/observation";
129
103
  import { hasOwnershipMarker, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
130
- import { buildOwnershipSets, pruneByOwnership, type OwnershipSets } from "@intentius/chant/managed-fields";
104
+ import { buildOwnershipSets, type OwnershipSets } from "@intentius/chant/managed-fields";
131
105
  import { defaultK8sConnector, type K8sConnector } from "./api/connect";
132
106
  import {
133
107
  classifyApiFailure,
@@ -158,21 +132,6 @@ export interface K8sDeepObserveOptions {
158
132
  cwd?: string;
159
133
  }
160
134
 
161
- /**
162
- * The managed-fields prune, composed with the static rules, for one
163
- * resource's normalization call. See the module doc for the three-question
164
- * rule this encodes (`@intentius/chant/managed-fields`'s `pruneByOwnership`).
165
- */
166
- function perResourceHooks(sets: OwnershipSets): DeepNormalizationHooks {
167
- return {
168
- prune(node) {
169
- if (k8sDeepNormalizationHooks.prune?.(node)) return true;
170
- return pruneByOwnership(node, sets);
171
- },
172
- orderKey: k8sDeepNormalizationHooks.orderKey,
173
- };
174
- }
175
-
176
135
  /** The live object minus the envelope fields that live outside `properties` on {@link DeepResourceObservation} (mirrors `type`/`physicalId`). */
177
136
  function propertiesTreeOf(obj: K8sObject): Record<string, unknown> {
178
137
  const { apiVersion: _apiVersion, kind: _kind, ...rest } = obj;
@@ -282,14 +241,13 @@ export async function observeResourcesDeepK8s(
282
241
  properties: normalizeDeepProperties(liveRoot, {
283
242
  entityType,
284
243
  side: "live",
285
- hooks: perResourceHooks(sets),
244
+ hooks: k8sDeepNormalizationHooks,
286
245
  }),
287
- // Who owns each surviving path (#1189). The prune above already
288
- // dropped confidently-foreign undeclared noise, so what reaches the
289
- // diff is chant's own fields and contested ones and for a contested
290
- // field, naming the manager is the whole question: an operator needs to
291
- // tell `hpa-controller` doing its job from somebody running
292
- // `kubectl edit`.
246
+ // 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`.
293
251
  ...(sets.owners.size > 0 ? { fieldOwners: Object.fromEntries(sets.owners) } : {}),
294
252
  };
295
253
  } catch (err) {