@intentius/chant-k8s-client 0.31.0 → 0.32.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.
@@ -0,0 +1,257 @@
1
+ /**
2
+ * The server-side-apply conflict surface — chant #1075.
3
+ *
4
+ * When an apply touches a field another manager owns, the API server refuses
5
+ * with a 409 and — unlike almost every other Kubernetes failure — tells you
6
+ * precisely what went wrong: which fields, and who owns each one. It arrives as
7
+ * a `Status` whose `details.causes` is a list of `FieldManagerConflict` entries:
8
+ *
9
+ * ```json
10
+ * { "reason": "Conflict", "code": 409,
11
+ * "message": "Apply failed with 2 conflicts: conflicts with \"kubectl\" ...",
12
+ * "details": { "causes": [
13
+ * { "type": "FieldManagerConflict", "message": "conflict with \"kubectl\"",
14
+ * "field": ".spec.replicas" } ] } }
15
+ * ```
16
+ *
17
+ * chant #1074 already carried that through as a typed `K8sApiError` with
18
+ * `conflict === true`. What was missing is the presentation: a 409 whose
19
+ * message is one run-on line is not something an operator can act on, and the
20
+ * thing they must not do — force it because the error suggested nothing else —
21
+ * is exactly what a bad message invites.
22
+ *
23
+ * So this error names the competing manager, lists the contested paths under
24
+ * it, says what forcing would mean, and stops there. **chant never forces a
25
+ * conflict on its own.** Taking a field from another manager is a decision
26
+ * about who owns production, and a tool that makes it silently is the reason
27
+ * "it works on my cluster" happens. The opt-in exists (`force`), it is never
28
+ * the default, and nothing in chant turns it on for you.
29
+ */
30
+
31
+ import { K8sApiError, type K8sStatus } from "./errors";
32
+
33
+ /** One contested field: a path, and the manager that owns it. */
34
+ export interface FieldConflict {
35
+ /** The manager that currently owns the field, e.g. `kubectl`, `helm`. */
36
+ manager: string;
37
+ /**
38
+ * The field path, in the same syntax `./managed-fields.ts` renders — e.g.
39
+ * `.spec.replicas`, `.spec.template.spec.containers[name="web"].image`.
40
+ */
41
+ field: string;
42
+ /** The apiVersion the owning entry was recorded at, when the server said. */
43
+ apiVersion?: string;
44
+ }
45
+
46
+ /**
47
+ * A server-side apply was refused because another field manager owns fields
48
+ * this apply would set.
49
+ *
50
+ * Extends {@link K8sApiError} so every existing `instanceof K8sApiError` check
51
+ * (and the `conflict` predicate) keeps working — this is a presentation of the
52
+ * 409, not a replacement for it.
53
+ */
54
+ export class FieldManagerConflictError extends K8sApiError {
55
+ /** Every contested field, in the order the server reported them. */
56
+ public readonly conflicts: FieldConflict[];
57
+ /** The field manager chant applied as, and which was refused. */
58
+ public readonly fieldManager: string;
59
+
60
+ constructor(
61
+ statusCode: number,
62
+ apiMessage: string,
63
+ conflicts: FieldConflict[],
64
+ fieldManager: string,
65
+ target?: string,
66
+ status?: K8sStatus,
67
+ ) {
68
+ super(statusCode, status?.reason ?? "Conflict", apiMessage, target, status);
69
+ this.name = "FieldManagerConflictError";
70
+ this.conflicts = conflicts;
71
+ this.fieldManager = fieldManager;
72
+ // Message is assembled after the fields exist, since it renders them.
73
+ this.message = renderConflictReport({ conflicts, fieldManager, target, apiMessage });
74
+ }
75
+
76
+ /** Contested paths grouped by the manager that owns them, managers sorted. */
77
+ get byManager(): Record<string, string[]> {
78
+ const grouped: Record<string, string[]> = {};
79
+ for (const conflict of this.conflicts) {
80
+ (grouped[conflict.manager] ??= []).push(conflict.field);
81
+ }
82
+ return Object.fromEntries(
83
+ Object.entries(grouped)
84
+ .sort(([a], [b]) => a.localeCompare(b))
85
+ .map(([manager, fields]) => [manager, [...fields].sort()]),
86
+ );
87
+ }
88
+
89
+ /** The competing managers, sorted. */
90
+ get managers(): string[] {
91
+ return Object.keys(this.byManager);
92
+ }
93
+
94
+ /** The contested paths, sorted and deduplicated. */
95
+ get fields(): string[] {
96
+ return [...new Set(this.conflicts.map((c) => c.field))].sort();
97
+ }
98
+ }
99
+
100
+ /** Inputs {@link renderConflictReport} needs; broken out so tests can render directly. */
101
+ export interface ConflictReport {
102
+ conflicts: FieldConflict[];
103
+ fieldManager: string;
104
+ target?: string;
105
+ /** The server's own message, used verbatim when nothing could be parsed. */
106
+ apiMessage?: string;
107
+ }
108
+
109
+ /**
110
+ * The operator-facing rendering. Three things, in this order: what is
111
+ * contested and who holds it, what chant applied as, and what the two ways out
112
+ * actually mean. No recommendation between them — that is the point.
113
+ */
114
+ export function renderConflictReport(report: ConflictReport): string {
115
+ const { conflicts, fieldManager, target } = report;
116
+ const subject = target ? `${target}` : "this object";
117
+
118
+ if (conflicts.length === 0) {
119
+ return (
120
+ `k8s: server-side apply of ${subject} was refused with a field-ownership conflict, but the ` +
121
+ `API server reported no field causes${report.apiMessage ? ` — it said: ${report.apiMessage}` : ""}. ` +
122
+ `chant applied as field manager "${fieldManager}".`
123
+ );
124
+ }
125
+
126
+ const grouped = new Map<string, string[]>();
127
+ for (const conflict of conflicts) {
128
+ const list = grouped.get(conflict.manager) ?? [];
129
+ if (!list.includes(conflict.field)) list.push(conflict.field);
130
+ grouped.set(conflict.manager, list);
131
+ }
132
+
133
+ const count = new Set(conflicts.map((c) => c.field)).size;
134
+ const lines = [
135
+ `k8s: server-side apply of ${subject} was refused — ` +
136
+ `${count} ${count === 1 ? "field is" : "fields are"} owned by another field manager.`,
137
+ "",
138
+ ];
139
+ for (const [manager, fields] of [...grouped].sort(([a], [b]) => a.localeCompare(b))) {
140
+ lines.push(` "${manager}" owns:`);
141
+ for (const field of [...fields].sort()) lines.push(` ${field}`);
142
+ }
143
+ lines.push(
144
+ "",
145
+ `chant applied as field manager "${fieldManager}". Taking these fields means the managers above`,
146
+ `stop owning them, and will contest them again on their next apply.`,
147
+ "",
148
+ "chant does not force this for you. Either:",
149
+ " - remove the contested fields from your chant source, leaving them to their current owner; or",
150
+ " - re-run this apply with force-conflicts on, deliberately (the `force: true` activity argument,",
151
+ " or `forceConflicts: true` on ApplyOp), which transfers ownership to chant.",
152
+ );
153
+ return lines.join("\n");
154
+ }
155
+
156
+ /**
157
+ * Pull the field causes out of a 409 `Status`.
158
+ *
159
+ * `details.causes` is the machine-readable form and is preferred. Not every
160
+ * server fills it in — an aggregated API server or an older release puts the
161
+ * same information only in the prose `message` — so the message is parsed as a
162
+ * fallback rather than the list being reported as empty.
163
+ */
164
+ export function parseFieldConflicts(status: K8sStatus | undefined, message?: string): FieldConflict[] {
165
+ const fromCauses = causesOf(status);
166
+ if (fromCauses.length > 0) return fromCauses;
167
+ return parseConflictMessage(message ?? status?.message ?? "");
168
+ }
169
+
170
+ interface StatusCause {
171
+ type?: string;
172
+ message?: string;
173
+ field?: string;
174
+ }
175
+
176
+ function causesOf(status: K8sStatus | undefined): FieldConflict[] {
177
+ const details = status?.details;
178
+ if (!details || typeof details !== "object") return [];
179
+ const causes = (details as { causes?: unknown }).causes;
180
+ if (!Array.isArray(causes)) return [];
181
+ const out: FieldConflict[] = [];
182
+ for (const raw of causes as StatusCause[]) {
183
+ if (!raw || typeof raw !== "object") continue;
184
+ if (raw.type !== undefined && raw.type !== "FieldManagerConflict") continue;
185
+ const field = typeof raw.field === "string" ? raw.field : undefined;
186
+ if (!field) continue;
187
+ const { manager, apiVersion } = parseCauseMessage(raw.message ?? "");
188
+ out.push({
189
+ manager: manager ?? "an unnamed manager",
190
+ field,
191
+ ...(apiVersion ? { apiVersion } : {}),
192
+ });
193
+ }
194
+ return out;
195
+ }
196
+
197
+ /** `conflict with "kubectl" using apps/v1` → manager + apiVersion. */
198
+ function parseCauseMessage(message: string): { manager?: string; apiVersion?: string } {
199
+ const manager = /conflicts? with "([^"]+)"/.exec(message)?.[1];
200
+ const apiVersion = /\busing ([^\s:]+)\b/.exec(message)?.[1];
201
+ return { ...(manager ? { manager } : {}), ...(apiVersion ? { apiVersion } : {}) };
202
+ }
203
+
204
+ /**
205
+ * Parse the prose form, which the server builds as one `conflicts with "x"`
206
+ * clause per manager followed by that manager's fields as a `-` list:
207
+ *
208
+ * ```
209
+ * Apply failed with 2 conflicts: conflicts with "kubectl" using apps/v1:
210
+ * - .spec.replicas
211
+ * - .spec.template.spec.containers[name="web"].image
212
+ * ```
213
+ */
214
+ export function parseConflictMessage(message: string): FieldConflict[] {
215
+ if (!message) return [];
216
+ const out: FieldConflict[] = [];
217
+ let manager: string | undefined;
218
+ let apiVersion: string | undefined;
219
+
220
+ for (const rawLine of message.split("\n")) {
221
+ const line = rawLine.trim();
222
+ if (line.length === 0) continue;
223
+
224
+ const header = /conflicts? with "([^"]+)"(?: using ([^\s:]+))?/.exec(line);
225
+ if (header) {
226
+ manager = header[1];
227
+ apiVersion = header[2];
228
+ // Single-conflict messages inline the field: `... with "kubectl": .spec.replicas`
229
+ const inline = /:\s*(\.[^\s]+)$/.exec(line);
230
+ if (inline) out.push({ manager, field: inline[1], ...(apiVersion ? { apiVersion } : {}) });
231
+ continue;
232
+ }
233
+
234
+ const bullet = /^-\s*(\S.*)$/.exec(line);
235
+ if (bullet && manager) {
236
+ out.push({ manager, field: bullet[1].trim(), ...(apiVersion ? { apiVersion } : {}) });
237
+ }
238
+ }
239
+ return out;
240
+ }
241
+
242
+ /**
243
+ * Turn a 409 from an apply into the presented error. Any other failure is
244
+ * returned unchanged — this is a narrowing, not a catch-all.
245
+ */
246
+ export function asFieldManagerConflict(error: unknown, fieldManager: string): unknown {
247
+ if (!(error instanceof K8sApiError) || !error.conflict) return error;
248
+ if (error instanceof FieldManagerConflictError) return error;
249
+ return new FieldManagerConflictError(
250
+ error.statusCode,
251
+ error.apiMessage,
252
+ parseFieldConflicts(error.status, error.apiMessage),
253
+ fieldManager,
254
+ error.target,
255
+ error.status,
256
+ );
257
+ }
package/src/errors.ts CHANGED
@@ -56,7 +56,11 @@ export class K8sApiError extends Error {
56
56
  return this.statusCode === 401 || this.reason === "Unauthorized";
57
57
  }
58
58
 
59
- /** Server-side-apply field-ownership conflict (chant #1075 surfaces these properly). */
59
+ /**
60
+ * Server-side-apply field-ownership conflict. `./conflict.ts`'s
61
+ * {@link import("./conflict").FieldManagerConflictError} is the presented
62
+ * form (chant #1075); this predicate still answers for both.
63
+ */
60
64
  get conflict(): boolean {
61
65
  return this.statusCode === 409 || this.reason === "Conflict";
62
66
  }
@@ -138,6 +142,19 @@ export class ExecCredentialNotAllowedError extends Error {
138
142
  }
139
143
  }
140
144
 
145
+ /**
146
+ * chant's own field-manager identity is unusable (chant #1075) — almost always
147
+ * because `ownership.stack` is too long or carries whitespace. Raised where the
148
+ * name is derived, before any request, so the config key can be named instead
149
+ * of the failure arriving as a 400 from a cluster.
150
+ */
151
+ export class FieldManagerError extends Error {
152
+ constructor(message: string) {
153
+ super(`k8s: ${message}`);
154
+ this.name = "FieldManagerError";
155
+ }
156
+ }
157
+
141
158
  /** The kubeconfig could not be read, or names no usable cluster/context. */
142
159
  export class KubeConfigError extends Error {
143
160
  constructor(message: string) {
@@ -0,0 +1,110 @@
1
+ /**
2
+ * chant's field-manager identity (chant #1075).
3
+ *
4
+ * The scheme is small enough to state in one line — `chant`, or `chant:<stack>`
5
+ * — so what is worth testing is the edges: that it is derived from the same
6
+ * `ownership.stack` the label marker uses, that it round-trips, and that an
7
+ * identity the API server would reject fails here, where the config key
8
+ * responsible can be named.
9
+ */
10
+
11
+ import { describe, test, expect } from "vitest";
12
+ import {
13
+ CHANT_FIELD_MANAGER,
14
+ FIELD_MANAGER_MAX_LENGTH,
15
+ assertValidFieldManager,
16
+ chantStackOf,
17
+ fieldManagerFor,
18
+ isChantFieldManager,
19
+ } from "./field-manager";
20
+ import { FieldManagerError } from "./errors";
21
+
22
+ describe("fieldManagerFor", () => {
23
+ test("no ownership stack yields the bare chant", () => {
24
+ expect(fieldManagerFor()).toBe("chant");
25
+ expect(fieldManagerFor({})).toBe("chant");
26
+ expect(fieldManagerFor({ stack: "" })).toBe("chant");
27
+ expect(fieldManagerFor({ stack: " " })).toBe("chant");
28
+ });
29
+
30
+ test("a stack qualifies it — the identity the ownership label already carries", () => {
31
+ expect(fieldManagerFor({ stack: "web" })).toBe("chant:web");
32
+ expect(fieldManagerFor({ stack: "platform-prod" })).toBe("chant:platform-prod");
33
+ });
34
+
35
+ test("two stacks on one cluster are two managers, which is the point", () => {
36
+ expect(fieldManagerFor({ stack: "a" })).not.toBe(fieldManagerFor({ stack: "b" }));
37
+ });
38
+
39
+ test("it is stable — the same stack derives the same manager every time", () => {
40
+ expect(fieldManagerFor({ stack: "web" })).toBe(fieldManagerFor({ stack: "web" }));
41
+ });
42
+
43
+ test("surrounding whitespace is trimmed rather than baked into the identity", () => {
44
+ expect(fieldManagerFor({ stack: " web " })).toBe("chant:web");
45
+ });
46
+ });
47
+
48
+ describe("recognising chant's own managers", () => {
49
+ test("qualified and unqualified are both chant's", () => {
50
+ expect(isChantFieldManager("chant")).toBe(true);
51
+ expect(isChantFieldManager("chant:web")).toBe(true);
52
+ });
53
+
54
+ test("another tool's manager is not, and neither is a lookalike prefix", () => {
55
+ for (const other of ["kubectl", "helm", "argo-controller", "chanted", "chant-ish", "", undefined]) {
56
+ expect(isChantFieldManager(other)).toBe(false);
57
+ }
58
+ });
59
+
60
+ test("the stack round-trips out of the manager", () => {
61
+ expect(chantStackOf(fieldManagerFor({ stack: "web" }))).toBe("web");
62
+ expect(chantStackOf("chant")).toBeUndefined();
63
+ expect(chantStackOf("kubectl")).toBeUndefined();
64
+ expect(chantStackOf(undefined)).toBeUndefined();
65
+ });
66
+
67
+ test("a stack containing the separator still round-trips whole", () => {
68
+ const manager = fieldManagerFor({ stack: "team:web" });
69
+ expect(manager).toBe("chant:team:web");
70
+ expect(chantStackOf(manager)).toBe("team:web");
71
+ });
72
+ });
73
+
74
+ describe("identities the API server would reject fail here instead", () => {
75
+ test("over the length ceiling, naming the config key", () => {
76
+ const stack = "s".repeat(FIELD_MANAGER_MAX_LENGTH);
77
+ const err = (() => {
78
+ try {
79
+ fieldManagerFor({ stack });
80
+ return undefined;
81
+ } catch (e) {
82
+ return e;
83
+ }
84
+ })();
85
+ expect(err).toBeInstanceOf(FieldManagerError);
86
+ expect(String(err)).toContain("ownership.stack");
87
+ expect(String(err)).toContain(String(FIELD_MANAGER_MAX_LENGTH));
88
+ });
89
+
90
+ test("an interior space, which would otherwise arrive as a 400 from a cluster", () => {
91
+ expect(() => fieldManagerFor({ stack: "web prod" })).toThrow(FieldManagerError);
92
+ });
93
+
94
+ test("a control character", () => {
95
+ expect(() => fieldManagerFor({ stack: `web${String.fromCharCode(9)}prod` })).toThrow(FieldManagerError);
96
+ });
97
+
98
+ test("an explicitly supplied manager is checked the same way", () => {
99
+ expect(() => assertValidFieldManager("")).toThrow(FieldManagerError);
100
+ expect(() => assertValidFieldManager("a".repeat(FIELD_MANAGER_MAX_LENGTH + 1))).toThrow(FieldManagerError);
101
+ expect(() => assertValidFieldManager(CHANT_FIELD_MANAGER)).not.toThrow();
102
+ // Someone else's manager is a legal value — this validates syntax, not ownership.
103
+ expect(() => assertValidFieldManager("kubectl-client-side-apply")).not.toThrow();
104
+ });
105
+
106
+ test("a stack exactly at the ceiling is allowed", () => {
107
+ const stack = "s".repeat(FIELD_MANAGER_MAX_LENGTH - "chant:".length);
108
+ expect(fieldManagerFor({ stack })).toHaveLength(FIELD_MANAGER_MAX_LENGTH);
109
+ });
110
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * chant's field-manager identity — chant #1075.
3
+ *
4
+ * Server-side apply records, per field, the name of the manager that last
5
+ * wrote it. That name is chant's identity in the cluster, so it has to be
6
+ * *stable* (the same stack applying twice must be the same manager, or the
7
+ * second apply conflicts with the first) and *distinguishing* (two chant
8
+ * stacks sharing a cluster must not silently co-own each other's fields).
9
+ *
10
+ * The scheme is `chant` alone, or `chant:<stack>` when the project sets
11
+ * `ownership.stack` — the same identity the label-based ownership marker
12
+ * already carries (`packages/core/src/ownership.ts`). Ownership-by-label
13
+ * answers a binary whole-object question; the field manager is the sub-object
14
+ * version of the same fact, supplied by the API server rather than stamped by
15
+ * chant. One identity, two granularities.
16
+ *
17
+ * **`ownership.env` is deliberately not part of it.** Two environments of one
18
+ * stack only ever touch the same object if they share a namespace and a name,
19
+ * and at that point they are fighting over it. An env-qualified manager would
20
+ * let them each own a different half of that object without either noticing;
21
+ * a stack-qualified one makes the second apply conflict, which is the correct
22
+ * outcome and the whole point of the conflict surface.
23
+ *
24
+ * This module is plain string logic with no dependency on chant core, so the
25
+ * lexicon (which reads `ownership` from project config) and the client (which
26
+ * writes the query parameter) agree on one derivation rather than two.
27
+ */
28
+
29
+ import { FieldManagerError } from "./errors";
30
+
31
+ /** The unqualified manager, used when a project sets no ownership stack. */
32
+ export const CHANT_FIELD_MANAGER = "chant";
33
+
34
+ /** Separator between the `chant` prefix and the stack identity. */
35
+ export const FIELD_MANAGER_SEPARATOR = ":";
36
+
37
+ /**
38
+ * The API server's own ceiling on `fieldManager`
39
+ * (`k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager`). Exceeding it is a
40
+ * 400 on every apply, so it is checked here — where the name is derived and
41
+ * the offending config key can be named — rather than discovered in a cluster.
42
+ */
43
+ export const FIELD_MANAGER_MAX_LENGTH = 128;
44
+
45
+ /** The stack identity a field manager is derived from. */
46
+ export interface FieldManagerIdentity {
47
+ /** `ownership.stack` from project config, when set. */
48
+ stack?: string;
49
+ }
50
+
51
+ /**
52
+ * Derive the field manager for a stack. `undefined`/no stack yields the bare
53
+ * `chant`; a stack yields `chant:<stack>`.
54
+ */
55
+ export function fieldManagerFor(identity?: FieldManagerIdentity): string {
56
+ const stack = identity?.stack?.trim();
57
+ if (!stack) return CHANT_FIELD_MANAGER;
58
+ const manager = `${CHANT_FIELD_MANAGER}${FIELD_MANAGER_SEPARATOR}${stack}`;
59
+ assertValidFieldManager(manager, stack);
60
+ return manager;
61
+ }
62
+
63
+ /**
64
+ * Reject a field manager the API server would reject, naming the config key
65
+ * responsible. A silent truncation would be worse than a failure: it would
66
+ * merge two stacks' identities into one and make their applies fight.
67
+ */
68
+ export function assertValidFieldManager(manager: string, stack?: string): void {
69
+ const source = stack === undefined ? `field manager "${manager}"` : `ownership.stack "${stack}"`;
70
+ if (manager.length === 0) {
71
+ throw new FieldManagerError(`${source} produces an empty field manager, which the API server rejects`);
72
+ }
73
+ if (manager.length > FIELD_MANAGER_MAX_LENGTH) {
74
+ throw new FieldManagerError(
75
+ `${source} produces the field manager "${manager}" (${manager.length} characters), ` +
76
+ `over the API server's ${FIELD_MANAGER_MAX_LENGTH}-character limit`,
77
+ );
78
+ }
79
+ // Space (0x20), every C0 control, and DEL. A code-point scan rather than a
80
+ // regexp, so no literal control character ever appears in this source.
81
+ const badIndex = [...manager].findIndex((ch) => {
82
+ const code = ch.codePointAt(0) ?? 0;
83
+ return code <= 0x20 || code === 0x7f;
84
+ });
85
+ if (badIndex !== -1) {
86
+ throw new FieldManagerError(
87
+ `${source} produces the field manager "${manager}", which contains whitespace or a control ` +
88
+ `character at position ${badIndex}. A field manager is an identity recorded on every object ` +
89
+ `chant applies; keep it to printable, space-free text.`,
90
+ );
91
+ }
92
+ }
93
+
94
+ /** True when `manager` is a chant field manager, qualified or not. */
95
+ export function isChantFieldManager(manager: string | undefined): boolean {
96
+ if (!manager) return false;
97
+ return (
98
+ manager === CHANT_FIELD_MANAGER ||
99
+ manager.startsWith(`${CHANT_FIELD_MANAGER}${FIELD_MANAGER_SEPARATOR}`)
100
+ );
101
+ }
102
+
103
+ /**
104
+ * The stack a chant field manager names, or undefined for the unqualified
105
+ * `chant` and for any manager that is not chant's at all.
106
+ */
107
+ export function chantStackOf(manager: string | undefined): string | undefined {
108
+ if (!manager || !isChantFieldManager(manager)) return undefined;
109
+ const stack = manager.slice(CHANT_FIELD_MANAGER.length + FIELD_MANAGER_SEPARATOR.length);
110
+ return stack.length > 0 ? stack : undefined;
111
+ }
package/src/index.ts CHANGED
@@ -22,18 +22,53 @@ export {
22
22
  selectorText,
23
23
  refText,
24
24
  } from "./client";
25
- export type { K8sClient, ReadOptions, ApplyOptions } from "./client";
25
+ export type { K8sClient, ReadOptions, ApplyOptions, DeleteOptions, ListOptions, ReadLogOptions } from "./client";
26
26
 
27
27
  export {
28
28
  K8sApiError,
29
29
  K8sTransportError,
30
30
  K8sClientUnavailableError,
31
31
  ExecCredentialNotAllowedError,
32
+ FieldManagerError,
32
33
  KubeConfigError,
33
34
  UnknownResourceError,
34
35
  } from "./errors";
35
36
  export type { K8sStatus } from "./errors";
36
37
 
38
+ // chant's field-manager identity and the conflict surface — chant #1075.
39
+ export {
40
+ CHANT_FIELD_MANAGER,
41
+ FIELD_MANAGER_SEPARATOR,
42
+ FIELD_MANAGER_MAX_LENGTH,
43
+ fieldManagerFor,
44
+ assertValidFieldManager,
45
+ isChantFieldManager,
46
+ chantStackOf,
47
+ } from "./field-manager";
48
+ export type { FieldManagerIdentity } from "./field-manager";
49
+
50
+ export {
51
+ FieldManagerConflictError,
52
+ asFieldManagerConflict,
53
+ parseFieldConflicts,
54
+ parseConflictMessage,
55
+ renderConflictReport,
56
+ } from "./conflict";
57
+ export type { FieldConflict, ConflictReport } from "./conflict";
58
+
59
+ // managedFields primitives — the material chant #1076's deep observation reads.
60
+ export {
61
+ managedFieldsOf,
62
+ fieldSetsOf,
63
+ managersOf,
64
+ fieldsOwnedBy,
65
+ chantOwnedFields,
66
+ fieldOwners,
67
+ fieldPathsOf,
68
+ renderSegment,
69
+ } from "./managed-fields";
70
+ export type { ManagedFieldsEntry, ManagerFieldSet } from "./managed-fields";
71
+
37
72
  export {
38
73
  DEFAULT_EXEC_ALLOWLIST,
39
74
  assertExecCredentialAllowed,