typekro 0.25.0 → 0.27.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 (85) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/alchemy/kro-delete.d.ts +8 -0
  3. package/dist/alchemy/kro-delete.d.ts.map +1 -1
  4. package/dist/alchemy/kro-delete.js +34 -35
  5. package/dist/alchemy/kro-delete.js.map +1 -1
  6. package/dist/alchemy/resource-registration.d.ts +60 -0
  7. package/dist/alchemy/resource-registration.d.ts.map +1 -1
  8. package/dist/alchemy/resource-registration.js +290 -11
  9. package/dist/alchemy/resource-registration.js.map +1 -1
  10. package/dist/alchemy/types.d.ts +44 -0
  11. package/dist/alchemy/types.d.ts.map +1 -1
  12. package/dist/core/deployment/direct-factory.d.ts +8 -1
  13. package/dist/core/deployment/direct-factory.d.ts.map +1 -1
  14. package/dist/core/deployment/direct-factory.js +80 -61
  15. package/dist/core/deployment/direct-factory.js.map +1 -1
  16. package/dist/core/deployment/engine.d.ts +2 -0
  17. package/dist/core/deployment/engine.d.ts.map +1 -1
  18. package/dist/core/deployment/engine.js +22 -1
  19. package/dist/core/deployment/engine.js.map +1 -1
  20. package/dist/core/deployment/k8s-helpers.d.ts +12 -0
  21. package/dist/core/deployment/k8s-helpers.d.ts.map +1 -1
  22. package/dist/core/deployment/k8s-helpers.js +26 -1
  23. package/dist/core/deployment/k8s-helpers.js.map +1 -1
  24. package/dist/core/deployment/kro-factory.d.ts +248 -1
  25. package/dist/core/deployment/kro-factory.d.ts.map +1 -1
  26. package/dist/core/deployment/kro-factory.js +1060 -180
  27. package/dist/core/deployment/kro-factory.js.map +1 -1
  28. package/dist/core/deployment/kro-instance-safety.d.ts +209 -0
  29. package/dist/core/deployment/kro-instance-safety.d.ts.map +1 -1
  30. package/dist/core/deployment/kro-instance-safety.js +506 -24
  31. package/dist/core/deployment/kro-instance-safety.js.map +1 -1
  32. package/dist/core/deployment/kro-namespace-teardown.d.ts +248 -0
  33. package/dist/core/deployment/kro-namespace-teardown.d.ts.map +1 -0
  34. package/dist/core/deployment/kro-namespace-teardown.js +459 -0
  35. package/dist/core/deployment/kro-namespace-teardown.js.map +1 -0
  36. package/dist/core/deployment/readiness-waiter.d.ts.map +1 -1
  37. package/dist/core/deployment/readiness-waiter.js +6 -2
  38. package/dist/core/deployment/readiness-waiter.js.map +1 -1
  39. package/dist/core/deployment/rollback-manager.d.ts +33 -1
  40. package/dist/core/deployment/rollback-manager.d.ts.map +1 -1
  41. package/dist/core/deployment/rollback-manager.js +85 -30
  42. package/dist/core/deployment/rollback-manager.js.map +1 -1
  43. package/dist/core/serialization/core.d.ts.map +1 -1
  44. package/dist/core/serialization/core.js +82 -8
  45. package/dist/core/serialization/core.js.map +1 -1
  46. package/dist/core/types/deployment.d.ts +20 -0
  47. package/dist/core/types/deployment.d.ts.map +1 -1
  48. package/dist/factories/index.d.ts +1 -0
  49. package/dist/factories/index.d.ts.map +1 -1
  50. package/dist/factories/index.js +4 -0
  51. package/dist/factories/index.js.map +1 -1
  52. package/dist/factories/nats/compositions/index.d.ts +2 -0
  53. package/dist/factories/nats/compositions/index.d.ts.map +1 -0
  54. package/dist/factories/nats/compositions/index.js +2 -0
  55. package/dist/factories/nats/compositions/index.js.map +1 -0
  56. package/dist/factories/nats/compositions/nats-bootstrap.d.ts +49 -0
  57. package/dist/factories/nats/compositions/nats-bootstrap.d.ts.map +1 -0
  58. package/dist/factories/nats/compositions/nats-bootstrap.js +135 -0
  59. package/dist/factories/nats/compositions/nats-bootstrap.js.map +1 -0
  60. package/dist/factories/nats/index.d.ts +5 -0
  61. package/dist/factories/nats/index.d.ts.map +1 -0
  62. package/dist/factories/nats/index.js +5 -0
  63. package/dist/factories/nats/index.js.map +1 -0
  64. package/dist/factories/nats/resources/helm.d.ts +28 -0
  65. package/dist/factories/nats/resources/helm.d.ts.map +1 -0
  66. package/dist/factories/nats/resources/helm.js +39 -0
  67. package/dist/factories/nats/resources/helm.js.map +1 -0
  68. package/dist/factories/nats/resources/index.d.ts +3 -0
  69. package/dist/factories/nats/resources/index.d.ts.map +1 -0
  70. package/dist/factories/nats/resources/index.js +3 -0
  71. package/dist/factories/nats/resources/index.js.map +1 -0
  72. package/dist/factories/nats/resources/jetstream.d.ts +13 -0
  73. package/dist/factories/nats/resources/jetstream.d.ts.map +1 -0
  74. package/dist/factories/nats/resources/jetstream.js +105 -0
  75. package/dist/factories/nats/resources/jetstream.js.map +1 -0
  76. package/dist/factories/nats/types.d.ts +91 -0
  77. package/dist/factories/nats/types.d.ts.map +1 -0
  78. package/dist/factories/nats/types.js +72 -0
  79. package/dist/factories/nats/types.js.map +1 -0
  80. package/dist/factories/rook/types.d.ts +1 -1
  81. package/dist/utils/string.d.ts +14 -0
  82. package/dist/utils/string.d.ts.map +1 -1
  83. package/dist/utils/string.js +22 -0
  84. package/dist/utils/string.js.map +1 -1
  85. package/package.json +5 -1
@@ -14,11 +14,13 @@ import { createCompositionContext, runWithCompositionContext } from '../composit
14
14
  import { buildNestedCompositionAliasTargets } from '../composition/nested-status-cel.js';
15
15
  import { DEFAULT_DEPLOYMENT_TIMEOUT, DEFAULT_KRO_INSTANCE_TIMEOUT, DEFAULT_RGD_TIMEOUT, } from '../config/defaults.js';
16
16
  import { CEL_EXPRESSION_BRAND, KUBERNETES_REF_SCHEMA_MARKER_SOURCE } from '../constants/brands.js';
17
- import { ConversionError, CRDInstanceError, ensureError, ResourceGraphFactoryError, TypeKroError, ValidationError, } from '../errors.js';
17
+ import { ConversionError, CRDInstanceError, DeploymentTimeoutError, ensureError, ResourceGraphFactoryError, TypeKroError, ValidationError, } from '../errors.js';
18
18
  import { isStrictCelDiagnosticsEnabled } from '../expressions/analysis/strict-cel.js';
19
19
  import { applyAnalysisToResources } from '../expressions/composition/composition-analyzer.js';
20
20
  import { createBunCompatibleKubernetesObjectApi } from '../kubernetes/index.js';
21
21
  import { getComponentLogger } from '../logging/index.js';
22
+ import { namespace as namespaceResource } from '../../factories/kubernetes/core/namespace.js';
23
+ import { shortStableHash, toCamelCase } from '../../utils/string.js';
22
24
  // Dependency inversion: kroCustomResource, resourceGraphDefinition, and
23
25
  // alchemy bridge are injected via FactoryOptions providers (Phase 3.5)
24
26
  // instead of dynamic import() from higher layers.
@@ -35,12 +37,20 @@ import { KubernetesClientManager } from './client-provider-manager.js';
35
37
  import { DirectDeploymentEngine } from './engine.js';
36
38
  import { logHandleSnapshot } from './handle-tracing.js';
37
39
  import { isNotFoundError } from './k8s-helpers.js';
38
- import { assertKroInstanceNamespaceOwnershipSafe, assertSingletonOwnerNamespaceOwnershipSafe, } from './kro-instance-safety.js';
40
+ import { assertKroInstanceNamespaceOwnershipSafe, assertNoHoistWeakenedStatusFields, assertSingletonOwnerNamespaceOwnershipSafe, concreteOwnedNamespaceResources, findDanglingHoistedReference, resolveConcreteMetadataValue, resolveNamespaceName, rewriteHoistedNamespaceReferences, rewriteHoistedNamespaceRefsInValue, selectHoistedNamespaces, } from './kro-instance-safety.js';
41
+ import { decideNamespaceOwnershipCreateFirst, deleteNamespaceIfEmpty, HOISTED_NAMESPACES_ANNOTATION, listNamespacesOwnedByRgd, NAMESPACE_OWNER_ANNOTATION, parseHoistedNamespacesAnnotation, readHoistedNamespacesRecord, } from './kro-namespace-teardown.js';
42
+ import { createRollbackManager } from './rollback-manager.js';
39
43
  import { waitForKroInstanceReady as waitForKroInstanceReadyShared } from './kro-readiness.js';
40
44
  import { evaluateSchemaCelExpression } from './schema-cel-evaluator.js';
41
45
  import { convertToKubernetesName, extractSerializableKubeConfigOptions, generateInstanceName, getSingletonInstanceName, pluralizeKind, validateSpec, } from './shared-utilities.js';
42
46
  import { joinYamlDocuments, singletonOwnerInstanceYamls, singletonRgdYamls, } from './singleton-gitops.js';
43
47
  import { assertNoDeployedSingletonSpecDrift, singletonSpecFingerprintAnnotationValue, } from './singleton-owner-drift.js';
48
+ /**
49
+ * Label stamped on every KRO instance CR this factory creates, keyed to the
50
+ * factory's RGD name. Used by the cluster-wide cleanup list in `deleteInstance`
51
+ * to decide whether other instances still share the RGD.
52
+ */
53
+ const INSTANCE_RGD_LABEL = 'typekro.io/rgd';
44
54
  /**
45
55
  * Decide whether the RGD/CRD should be preserved after a `deleteInstance`
46
56
  * call, i.e., whether other instances still depend on it.
@@ -187,6 +197,119 @@ export class KroResourceFactoryImpl {
187
197
  getInstanceApiVersion() {
188
198
  return `${this.getSchemaGroup()}/${this.getSchemaVersion()}`;
189
199
  }
200
+ /**
201
+ * Resolve the namespace the KRO instance CR lives in for this call.
202
+ *
203
+ * The default CR placement is the FACTORY namespace (`this.namespace`), exactly
204
+ * as v0.26.0 always did — the maintainer's explicit decision (finding #2). This
205
+ * fixes the silent-relocation regression: an upgrade never moves an existing CR
206
+ * versus the released v0.26.0, and NO per-call `spec.namespace` is consulted for
207
+ * CR placement. An explicit `instanceNamespace` factory option still wins as an
208
+ * override (the only way to place the CR elsewhere).
209
+ *
210
+ * Identity distinctness within a single alchemy stack comes from the instance
211
+ * NAME: the top-level alchemy id is the legacy namespace-agnostic kind+name (see
212
+ * {@link instanceAlchemyId}, finding #1). NOTE: a different k8s factory namespace
213
+ * does NOT by itself create a different alchemy scope — two factories with the same
214
+ * instance name but different namespaces expose the SAME alchemy id and, if
215
+ * materialized in the SAME alchemy stack, collide (last write wins). Isolating
216
+ * same-named instances (e.g. `analytics` in dev vs prod) is the CALLER's
217
+ * responsibility: put them in SEPARATE alchemy stacks/scopes. `spec.namespace` is
218
+ * never consulted for CR placement or identity.
219
+ *
220
+ * The `spec` parameter is accepted only for call-site symmetry with the other
221
+ * resolvers and is deliberately unused (CR placement must not vary per spec, or
222
+ * `getInstances`/`deleteInstance` — which have no spec — could target a different
223
+ * namespace than `deploy` and orphan the CR, finding #3).
224
+ *
225
+ * Finalizer-stranding safety comes from HOISTING the composition's own workload
226
+ * Namespace OUT of the RGD graph (see {@link hoistedNamespaceNames}), not from
227
+ * where the instance CR lives.
228
+ */
229
+ resolveInstanceNamespace(_spec) {
230
+ const explicit = this.factoryOptions.instanceNamespace;
231
+ if (explicit !== undefined)
232
+ return explicit;
233
+ return this.namespace;
234
+ }
235
+ /**
236
+ * Every Namespace HOISTED out of the RGD graph, mapped to its RAW `metadata.name`
237
+ * value — the UNCONDITIONAL model (typekro NEVER emits a Namespace into RGD YAML).
238
+ * Selection is trivially `kind === 'Namespace'` (excluding `__externalRef`
239
+ * observed namespaces); see {@link selectHoistedNamespaces}.
240
+ *
241
+ * This is a STABLE STRUCTURAL property of the RGD, computed WITHOUT any concrete
242
+ * spec: the set of hoisted ids is IDENTICAL for every instance of this factory, so
243
+ * the shared RGD never changes shape per-instance (deploying instance 2 can't
244
+ * mutate the graph instance 1 depends on). The name VALUES feed the reference
245
+ * rewriter so a reference to a hoisted Namespace's `metadata.name` becomes that
246
+ * Namespace's own concrete name expression.
247
+ */
248
+ hoistedNamespaceRefs(resources = this.resources) {
249
+ return selectHoistedNamespaces(resources);
250
+ }
251
+ /**
252
+ * The CONCRETE Namespace resources to emit as SIBLINGS (deps-first, outside the
253
+ * graph) for this spec, keyed by namespace NAME.
254
+ *
255
+ * The set of hoisted IDS is STRUCTURAL (spec-independent); resolving those ids to
256
+ * concrete resources needs the spec. Re-executing the composition against the
257
+ * concrete spec yields the FULL concrete Namespace metadata (name + all labels,
258
+ * incl. Pod Security, + all annotations), which the emission paths PRESERVE and
259
+ * merge retention onto (finding #5) — never a bare synthesized Namespace. A hoisted
260
+ * id that is inactive under THIS spec (e.g. `includeWhen: false`) or whose name
261
+ * can't be resolved is skipped, so emission stays per-spec and order-independent.
262
+ * The map value is the ORIGINAL concrete Namespace resource (keyed by resolved
263
+ * name).
264
+ */
265
+ concreteHoistedNamespaces(spec) {
266
+ const result = new Map();
267
+ const hoistIds = this.hoistedNamespaceRefs();
268
+ if (hoistIds.size === 0 || spec === undefined)
269
+ return result;
270
+ const compositionFn = this.factoryOptions.compositionFn;
271
+ const concreteById = concreteOwnedNamespaceResources({
272
+ compositionName: this.name,
273
+ spec,
274
+ resources: this.resources,
275
+ ...(compositionFn ? { compositionFn } : {}),
276
+ });
277
+ for (const id of hoistIds.keys()) {
278
+ const concrete = concreteById.get(id);
279
+ // Inactive under this spec (conditionally-excluded) → nothing to emit.
280
+ if (!concrete)
281
+ continue;
282
+ const name = resolveNamespaceName(concrete.metadata?.name, spec) ??
283
+ (typeof concrete.metadata?.name === 'string' ? concrete.metadata.name : undefined);
284
+ if (name === undefined)
285
+ continue;
286
+ result.set(name, concrete);
287
+ }
288
+ return result;
289
+ }
290
+ /**
291
+ * The concrete NAMES of the namespaces hoisted out of the graph for this spec —
292
+ * used to tell the ownership guard which owned namespace is now safe (no longer a
293
+ * graph child), and as the singleton keys for sibling emission.
294
+ */
295
+ hoistedNamespaceNames(spec) {
296
+ return new Set(this.concreteHoistedNamespaces(spec).keys());
297
+ }
298
+ /**
299
+ * The resource map serialized into the RGD: `resources` minus EVERY hoisted
300
+ * Namespace, with every remaining reference to a hoisted Namespace's
301
+ * `metadata.name` rewritten to that Namespace's own concrete name expression
302
+ * (finding #3) so the emitted RGD carries no dangling reference to a removed
303
+ * resource. The hoist decision is STRUCTURAL (spec-independent), so the RGD shape
304
+ * is stable across every instance of this factory (finding #4).
305
+ */
306
+ resourcesForRgd(resources) {
307
+ const hoistIds = this.hoistedNamespaceRefs(resources);
308
+ if (hoistIds.size === 0)
309
+ return resources;
310
+ const remaining = Object.fromEntries(Object.entries(resources).filter(([id]) => !hoistIds.has(id)));
311
+ return rewriteHoistedNamespaceReferences(remaining, hoistIds);
312
+ }
190
313
  /**
191
314
  * Idempotently create the factory's target namespace if it doesn't
192
315
  * exist. KRO does not auto-create the CR's containing namespace, and
@@ -318,12 +441,34 @@ export class KroResourceFactoryImpl {
318
441
  return undefined;
319
442
  }
320
443
  }
444
+ /**
445
+ * STRICT plural discovery (finding #3, fail-closed). Distinguishes a definitive
446
+ * "CRD absent" (a successful CRD list with no matching definition → fresh cluster)
447
+ * from a discovery FAILURE (RBAC/connectivity/list error) — the latter is NOT proof
448
+ * of absence, so it THROWS rather than being swallowed into `undefined` the way
449
+ * {@link lookupCRDPlural} does. Callers that must fail closed (the pre-hoist safety
450
+ * guard) use this; callers that may fall back to a heuristic keep using
451
+ * {@link lookupCRDPlural}.
452
+ */
453
+ async discoverGeneratedCrdPlural() {
454
+ const k8sApi = this.createKubernetesObjectApi();
455
+ // A LIST failure propagates (NOT caught) so callers FAIL CLOSED.
456
+ const crds = (await k8sApi.list('apiextensions.k8s.io/v1', 'CustomResourceDefinition'));
457
+ const match = crds?.items?.find((crd) => crd.spec?.group === this.getSchemaGroup() &&
458
+ crd.spec?.names?.kind === this.schemaDefinition.kind);
459
+ const plural = match?.spec?.names?.plural;
460
+ return plural ? { present: true, plural } : { present: false };
461
+ }
321
462
  async requireCRDPluralForCleanup() {
322
463
  if (!this.discoveredPlural) {
323
464
  this.discoveredPlural = await this.lookupCRDPlural();
324
465
  }
325
466
  if (!this.discoveredPlural) {
326
- throw new CRDInstanceError(`Cannot determine CRD plural for ${this.schemaDefinition.kind}; preserving RGD/CRD to avoid deleting shared KRO state`, this.schemaDefinition.apiVersion, this.schemaDefinition.kind, '*', 'deletion');
467
+ // Teardown-only guard: without the plural we cannot list instances to decide
468
+ // whether the RGD is shared, so the caller preserves the RGD (conservative). The
469
+ // generated CRD is intentionally RETAINED regardless (v4), so this no longer
470
+ // guards against orphaning it.
471
+ throw new CRDInstanceError(`Cannot determine CRD plural for ${this.schemaDefinition.kind}; preserving RGD to avoid deleting shared KRO state`, this.schemaDefinition.apiVersion, this.schemaDefinition.kind, '*', 'deletion');
327
472
  }
328
473
  return this.discoveredPlural;
329
474
  }
@@ -427,6 +572,11 @@ export class KroResourceFactoryImpl {
427
572
  singletonFactoryFor(definition) {
428
573
  return definition.composition.factory('kro', {
429
574
  namespace: definition.registryNamespace,
575
+ // Pin the singleton owner CR to its registry namespace (its externalRef
576
+ // consumers resolve against that namespace). This equals the workload
577
+ // namespace, so it is NOT decoupled — a singleton composition that owns
578
+ // the registry namespace is still rejected by the ownership guard.
579
+ instanceNamespace: definition.registryNamespace,
430
580
  waitForReady: true,
431
581
  ...(this.factoryOptions.timeout !== undefined
432
582
  ? { timeout: this.factoryOptions.timeout }
@@ -588,18 +738,42 @@ export class KroResourceFactoryImpl {
588
738
  * Deploy directly to Kubernetes using DirectDeploymentEngine
589
739
  */
590
740
  async deployDirect(spec, instanceNameOverride, singletonSpecFingerprint) {
591
- // Ensure RGD is deployed first
592
- await this.ensureRGDDeployed();
593
- // Ensure the target namespace exists before posting the CR. KRO
594
- // reconciles resources from the RGD into their own namespaces, but
595
- // the CR instance itself must live in a namespace the user can
596
- // write to. Without this, the first deploy after `kubectl delete ns`
597
- // fails with a 404 on the CR POST.
598
- await this.ensureTargetNamespace();
741
+ const instanceNamespace = this.resolveInstanceNamespace(spec);
742
+ const instanceName = instanceNameOverride ?? generateInstanceName(spec, this.name);
743
+ const hoistedNamespaces = this.concreteHoistedNamespaces(spec);
744
+ // FINDING #2: fail closed on a PRE-HOIST upgrade BEFORE touching the RGD. If a
745
+ // namespace this factory now hoists is currently a KRO ApplySet member (a namespace
746
+ // a pre-hoist RGD owned as a graph child), rolling the new hoisted RGD over the old
747
+ // one drops the namespace from the ApplySet — and KRO's prune then deletes the live
748
+ // namespace out from under the workload. Detect it and THROW (detection only — no
749
+ // automatic migration). See the migration doc for the one-time manual step.
750
+ await this.assertNoPreHoistNamespaceConflict(hoistedNamespaces.keys());
751
+ // Ensure RGD is deployed. Every owned Namespace is hoisted OUT of the RGD graph
752
+ // and applied as a SIBLING below (deps-first), never a graph child — so KRO never
753
+ // owns a namespace and deleting the instance can never garbage-collect the
754
+ // namespace holding its own finalizer. The hoist is STRUCTURAL, so the shared RGD
755
+ // shape is the same for every instance (finding #4).
756
+ await this.ensureRGDDeployed(spec);
757
+ // Ensure the namespace that will hold the CR exists before posting it. KRO
758
+ // reconciles resources from the RGD into their own namespaces, but the CR
759
+ // instance itself must live in a namespace the user can write to. If the
760
+ // composition owns that namespace it was hoisted out of the graph above, so
761
+ // creating it here (as a sibling) means KRO never garbage-collects it. Without
762
+ // this, the first deploy after `kubectl delete ns` fails with a 404 on the CR
763
+ // POST.
764
+ //
765
+ // A hoisted Namespace is applied with its COMPLETE preserved configuration
766
+ // (all labels incl. Pod Security + spec) plus retention markers; a non-hoisted
767
+ // instance namespace just needs to exist.
768
+ if (!hoistedNamespaces.has(instanceNamespace)) {
769
+ await this.ensureTargetNamespace(instanceNamespace);
770
+ }
771
+ for (const [name, original] of hoistedNamespaces) {
772
+ await this.applyRetainedHoistedNamespace(name, original, spec);
773
+ }
599
774
  // Create DirectDeploymentEngine with KRO mode for CEL string conversion
600
775
  const deploymentEngine = new DirectDeploymentEngine(this.getKubeConfig(), undefined, undefined, DeploymentMode.KRO);
601
776
  // Create custom resource instance
602
- const instanceName = instanceNameOverride ?? generateInstanceName(spec, this.name);
603
777
  const customResourceData = this.createCustomResourceInstance(instanceName, spec, singletonSpecFingerprint);
604
778
  // Wrap with kroCustomResource factory to get Enhanced object with readiness evaluation
605
779
  const kroCustomResource = this.kroCustomResourceProvider ??
@@ -619,7 +793,7 @@ export class KroResourceFactoryImpl {
619
793
  metadata: {
620
794
  ...enhancedCustomResource.metadata,
621
795
  name: instanceName,
622
- namespace: this.namespace,
796
+ namespace: instanceNamespace,
623
797
  },
624
798
  spec: customResourceData.spec, // Use spec directly from customResourceData to ensure it's preserved
625
799
  };
@@ -630,7 +804,7 @@ export class KroResourceFactoryImpl {
630
804
  try {
631
805
  await deploymentEngine.deployResource(deployableResource, {
632
806
  mode: 'kro',
633
- namespace: this.namespace,
807
+ namespace: instanceNamespace,
634
808
  waitForReady: false, // We'll handle Kro-specific readiness ourselves
635
809
  timeout: this.factoryOptions.timeout || DEFAULT_DEPLOYMENT_TIMEOUT,
636
810
  });
@@ -640,7 +814,7 @@ export class KroResourceFactoryImpl {
640
814
  });
641
815
  // Handle Kro-specific readiness checking if requested
642
816
  if (this.factoryOptions.waitForReady ?? true) {
643
- await this.waitForKroInstanceReady(instanceName, this.factoryOptions.timeout || DEFAULT_KRO_INSTANCE_TIMEOUT); // 10 minutes
817
+ await this.waitForKroInstanceReady(instanceName, this.factoryOptions.timeout || DEFAULT_KRO_INSTANCE_TIMEOUT, instanceNamespace); // 10 minutes
644
818
  }
645
819
  this.logger.info('Instance ready, creating enhanced proxy', {
646
820
  instanceName,
@@ -653,6 +827,264 @@ export class KroResourceFactoryImpl {
653
827
  await deploymentEngine.dispose();
654
828
  }
655
829
  }
830
+ /**
831
+ * Apply a hoisted workload Namespace as a RETAINED resource OUTSIDE the KRO
832
+ * graph, with its COMPLETE preserved configuration — all labels (incl. Pod
833
+ * Security + schema-derived), all annotations, AND spec — plus retention markers
834
+ * (finding #8), using typekro's own field manager (not KRO's). Server-side apply
835
+ * is idempotent and does not conflict with KRO because KRO no longer considers
836
+ * the Namespace desired.
837
+ *
838
+ * If the full Namespace cannot be applied, this FAILS (finding #8) rather than
839
+ * silently degrading to a bare `ensureTargetNamespace` that would drop Pod
840
+ * Security + other declared configuration.
841
+ */
842
+ async applyRetainedHoistedNamespace(name, original, spec) {
843
+ const merged = KroResourceFactoryImpl.mergedHoistedNamespaceMetadata(name, original, spec);
844
+ const k8sApi = createBunCompatibleKubernetesObjectApi(this.getKubeConfig());
845
+ const baseMetadata = {
846
+ name: merged.name,
847
+ labels: merged.labels,
848
+ ...(merged.finalizers !== undefined ? { finalizers: merged.finalizers } : {}),
849
+ ...(merged.ownerReferences !== undefined
850
+ ? { ownerReferences: merged.ownerReferences }
851
+ : {}),
852
+ };
853
+ const buildManifest = (annotations) => ({
854
+ apiVersion: 'v1',
855
+ kind: 'Namespace',
856
+ metadata: { ...baseMetadata, annotations },
857
+ ...(merged.spec !== undefined ? { spec: merged.spec } : {}),
858
+ });
859
+ const annotationsWithStamp = {
860
+ ...merged.annotations,
861
+ [NAMESPACE_OWNER_ANNOTATION]: this.rgdName,
862
+ };
863
+ // CREATE-FIRST ownership (finding #3): attempt to CREATE the namespace WITH the
864
+ // ownership stamp and complete declared config. A 201 is atomic proof WE created it
865
+ // (owned). A 409 means it already exists — owned ONLY if a prior create by this RGD
866
+ // stamped it; otherwise adopted (never stamped, so teardown never deletes it). This
867
+ // replaces the raceable GET→(404)→patch-with-stamp: no window in which another actor
868
+ // creates the namespace between our read and our stamping patch.
869
+ let decision;
870
+ try {
871
+ decision = await decideNamespaceOwnershipCreateFirst(k8sApi, buildManifest(annotationsWithStamp), this.rgdName);
872
+ }
873
+ catch (error) {
874
+ // A non-conflict CREATE failure (or a failed conflict-read) — do NOT fall back to a
875
+ // bare namespace (finding #8). Fail loudly so the operator sees the real problem.
876
+ throw new ResourceGraphFactoryError(`Failed to create the retained workload Namespace "${name}" with its complete declared ` +
877
+ `configuration (Pod Security labels, annotations, spec): ${ensureError(error).message}`, this.name, 'deployment', ensureError(error));
878
+ }
879
+ // 201 CREATE landed the full config + stamp atomically — nothing more to apply.
880
+ if (decision.created)
881
+ return;
882
+ // 409: the namespace already existed. Apply the retained config via SSA, keeping the
883
+ // ownership stamp ONLY if we already own it (adoption must NOT be stamped).
884
+ const annotations = decision.owned ? annotationsWithStamp : merged.annotations;
885
+ try {
886
+ await k8sApi.patch(buildManifest(annotations), undefined, undefined, 'typekro', // fieldManager — typekro's, NOT kro's
887
+ true, // force (own the retention fields)
888
+ 'application/apply-patch+yaml' // server-side apply
889
+ );
890
+ }
891
+ catch (error) {
892
+ // Do NOT fall back to a bare namespace (finding #8): that would drop the
893
+ // declared Pod Security labels + other config. Fail loudly so the operator
894
+ // sees the real problem instead of a silently-degraded workload namespace.
895
+ throw new ResourceGraphFactoryError(`Failed to apply the retained workload Namespace "${name}" with its complete declared ` +
896
+ `configuration (Pod Security labels, annotations, spec): ${ensureError(error).message}`, this.name, 'deployment', ensureError(error));
897
+ }
898
+ }
899
+ /**
900
+ * FINDING #2 — fail-closed PRE-HOIST detection (detection only; no migration).
901
+ *
902
+ * On deploy, BEFORE the RGD is (re)applied, check every namespace this factory would
903
+ * hoist. If a live namespace is currently a KRO ApplySet member — it carries
904
+ * `applyset.kubernetes.io/part-of` and/or any `kro.run/*` ownership label from a
905
+ * PRE-HOIST RGD (which owned the namespace as a graph child) — then rolling the new,
906
+ * hoisted RGD (which no longer lists the namespace) over the old one makes KRO's
907
+ * ApplySet prune enumerate and DELETE the live namespace, taking the workload with
908
+ * it. We THROW a clear, actionable error instead. This does NOT re-introduce the
909
+ * removed automatic migration/drain/strip machinery — it only refuses to proceed.
910
+ *
911
+ * typekro's own v2 sibling namespaces carry `typekro.io/kro-instance-namespace=true`
912
+ * but NEITHER an ApplySet `part-of` NOR any `kro.run/*` label (KRO never owns them),
913
+ * so steady-state re-deploys never trip this. A 404 (fresh) is not a pre-hoist signal.
914
+ *
915
+ * FINDING #7 (hardened): the check covers not only the namespaces resolved from the
916
+ * INCOMING spec but EVERY existing instance of this shared RGD (their hoisted
917
+ * namespaces too) — an upgrade prunes the ApplySet for ALL of them at once, so
918
+ * missing another instance's namespace would let KRO delete it. And reads FAIL
919
+ * CLOSED: a non-404 read error is NOT proof of safety, so it ABORTS the deploy
920
+ * rather than being skipped.
921
+ */
922
+ async assertNoPreHoistNamespaceConflict(hoistedNamespaceNames) {
923
+ const names = new Set(hoistedNamespaceNames);
924
+ // Union in every EXISTING instance's hoisted namespaces (finding #7): they are all
925
+ // dropped from the ApplySet by the same RGD roll. Fails closed on a non-absent list
926
+ // error.
927
+ for (const extra of await this.existingInstancesHoistedNamespaceNames()) {
928
+ names.add(extra);
929
+ }
930
+ if (names.size === 0)
931
+ return;
932
+ const k8sApi = this.createKubernetesObjectApi();
933
+ for (const name of names) {
934
+ let labels = {};
935
+ try {
936
+ const live = (await k8sApi.read({
937
+ apiVersion: 'v1',
938
+ kind: 'Namespace',
939
+ metadata: { name },
940
+ }));
941
+ labels = live.metadata?.labels ?? {};
942
+ }
943
+ catch (error) {
944
+ const k8sErr = error;
945
+ const code = k8sErr.statusCode ?? k8sErr.code ?? k8sErr.body?.code;
946
+ if (code === 404)
947
+ continue; // fresh namespace — nothing to migrate
948
+ // FAIL CLOSED (finding #7): a non-404 read error means we cannot prove the
949
+ // namespace is safe to hoist over. Abort rather than risk KRO's prune deleting
950
+ // a namespace whose pre-hoist ownership we could not read.
951
+ throw new TypeKroError(`Pre-hoist safety check could not read Namespace "${name}" for composition ` +
952
+ `"${this.name}" (${ensureError(error).message}). Refusing to deploy: a hoist over an ` +
953
+ `unreadable namespace could let KRO's ApplySet prune delete it. Resolve the read ` +
954
+ `error (RBAC/connectivity) and retry.`, 'PRE_HOIST_NAMESPACE_CHECK_FAILED', { composition: this.name, namespace: name, mode: 'kro' });
955
+ }
956
+ const partOfKro = typeof labels['applyset.kubernetes.io/part-of'] === 'string' ||
957
+ Object.keys(labels).some((key) => key.startsWith('kro.run/'));
958
+ if (partOfKro) {
959
+ throw new TypeKroError(`Pre-hoist deployment detected for composition "${this.name}": the live Namespace ` +
960
+ `"${name}" is a KRO ApplySet member (carries applyset.kubernetes.io/part-of and/or ` +
961
+ `kro.run/* ownership labels from a pre-hoist RGD). typekro now HOISTS every owned ` +
962
+ `Namespace out of the RGD, so applying the new RGD in place would drop "${name}" from ` +
963
+ `KRO's ApplySet and KRO's prune would DELETE the live namespace and its workloads. ` +
964
+ `This upgrade is not auto-migrated. Either recreate (delete the instance, then ` +
965
+ `redeploy — the namespace is recreated as a sibling) or perform the one-time manual ` +
966
+ `label-strip while the controller is quiesced. See docs/advanced/migration.md ` +
967
+ `("Upgrading from a pre-hoist TypeKro release").`, 'PRE_HOIST_NAMESPACE_CONFLICT', { composition: this.name, namespace: name, labels: Object.keys(labels), mode: 'kro' });
968
+ }
969
+ }
970
+ }
971
+ /**
972
+ * The hoisted-namespace names of EVERY existing instance of this shared RGD (finding
973
+ * #7), resolved EXACTLY and PER INSTANCE from that instance's OWN durable record
974
+ * (finding #1, v7) — never approximated, and never from an RGD-wide aggregate.
975
+ *
976
+ * Each instance's namespaces come from its OWN CR `typekro.io/hoisted-namespaces`
977
+ * annotation — the only PER-INSTANCE proof. An instance that lacks its own record (a
978
+ * genuinely-legacy deployment) FAILS CLOSED (throws): the RGD-wide set of Namespaces
979
+ * carrying `typekro.io/created-by-rgd == this.rgdName` is NOT per-instance proof — a
980
+ * DIFFERENT (modern) instance could have stamped it, so a non-empty RGD-wide set would
981
+ * otherwise MASK a legacy instance whose own — possibly different — namespace is invisible
982
+ * and would be pruned. We also never approximate from metadata.namespace / spec.namespace.
983
+ *
984
+ * The RGD-wide `created-by-rgd` set IS still unioned into the returned protected set (it
985
+ * is a superset that catches namespaces leaked by an interrupted teardown) — it just never
986
+ * SATISFIES the per-instance check. FRESH clusters (no CRD / no instances yet) yield an
987
+ * empty set; any list error FAILS CLOSED so the guard is never silently bypassed.
988
+ */
989
+ async existingInstancesHoistedNamespaceNames() {
990
+ const result = new Set();
991
+ // Short-circuit: if this RGD structurally hoists NO namespace, the hoist can prune nothing —
992
+ // there is nothing to protect and no per-instance record to require. An ordinary composition
993
+ // with no Namespace resources must never trip the pre-hoist legacy guard.
994
+ if (this.hoistedNamespaceRefs().size === 0)
995
+ return result;
996
+ // Discover the generated CRD via a STRICT lookup that THROWS on a real list error
997
+ // (finding #3, fail-closed): a discovery/RBAC failure is NOT proof the CRD is
998
+ // absent. Only a successful list with no matching CRD is a definitive "fresh
999
+ // cluster" (no prior instances to protect).
1000
+ let discovery;
1001
+ try {
1002
+ discovery = await this.discoverGeneratedCrdPlural();
1003
+ }
1004
+ catch (error) {
1005
+ throw new TypeKroError(`Pre-hoist safety check could not discover the generated CRD for RGD "${this.rgdName}" ` +
1006
+ `(${ensureError(error).message}). Refusing to deploy: a discovery/RBAC error is not ` +
1007
+ `proof the CRD is absent, and proceeding could let the hoist prune an existing ` +
1008
+ `instance's namespace unchecked.`, 'PRE_HOIST_CRD_DISCOVERY_FAILED', { composition: this.name, rgdName: this.rgdName, mode: 'kro' });
1009
+ }
1010
+ if (!discovery.present)
1011
+ return result; // definitively fresh — no prior instances
1012
+ let items;
1013
+ try {
1014
+ const customApi = await this.createCustomObjectsApi();
1015
+ const listResponse = await customApi.listClusterCustomObject({
1016
+ group: this.getSchemaGroup(),
1017
+ version: this.getSchemaVersion(),
1018
+ plural: discovery.plural,
1019
+ });
1020
+ items =
1021
+ listResponse.items ?? [];
1022
+ }
1023
+ catch (error) {
1024
+ // Only a DEFINITIVE 404 (the CRD/instances vanished between discovery and list)
1025
+ // is treated as fresh; ANY other error FAILS CLOSED (finding #3) — a message
1026
+ // that merely mentions "CRD"/"not found" no longer counts (that was the swallow
1027
+ // bug), only a real NotFound status.
1028
+ const k8sErr = error;
1029
+ const status = k8sErr.statusCode ?? k8sErr.code ?? k8sErr.body?.code;
1030
+ if (status === 404)
1031
+ return result;
1032
+ throw new TypeKroError(`Pre-hoist safety check could not list existing instances of RGD "${this.rgdName}" ` +
1033
+ `(${ensureError(error).message}). Refusing to deploy: another instance's namespace could ` +
1034
+ `be pruned by the hoist without being checked.`, 'PRE_HOIST_INSTANCE_LIST_FAILED', { composition: this.name, rgdName: this.rgdName, mode: 'kro' });
1035
+ }
1036
+ if (items.length === 0)
1037
+ return result;
1038
+ // The RGD-wide record of every namespace this RGD created: namespaces carrying
1039
+ // `typekro.io/created-by-rgd == this.rgdName`. We union it into the PROTECTED set as a
1040
+ // superset — it catches namespaces leaked by an interrupted teardown that no live CR
1041
+ // records. It is NOT used to satisfy the per-instance check below (finding #1, v7): being
1042
+ // RGD-wide, it cannot prove a SPECIFIC instance's namespaces. A list error FAILS CLOSED —
1043
+ // an unreadable list is not proof no owned namespace exists.
1044
+ let ownedNamespaces;
1045
+ try {
1046
+ ownedNamespaces = new Set(await listNamespacesOwnedByRgd(this.getKubeConfig(), this.rgdName, {
1047
+ k8sApi: this.createKubernetesObjectApi(),
1048
+ logger: this.logger,
1049
+ }));
1050
+ }
1051
+ catch (error) {
1052
+ throw new TypeKroError(`Pre-hoist safety check could not list namespaces owned by RGD "${this.rgdName}" ` +
1053
+ `(${ensureError(error).message}). Refusing to deploy: without the durable ownership ` +
1054
+ `record we cannot prove the hoist won't prune an existing instance's namespace.`, 'PRE_HOIST_OWNED_NAMESPACE_LIST_FAILED', { composition: this.name, rgdName: this.rgdName, mode: 'kro' });
1055
+ }
1056
+ // Every owned namespace is at risk of the ApplySet prune — protect them all.
1057
+ for (const ns of ownedNamespaces)
1058
+ result.add(ns);
1059
+ for (const item of items) {
1060
+ // Resolve THIS instance's namespaces from its OWN exact PER-INSTANCE record — the CR
1061
+ // `typekro.io/hoisted-namespaces` annotation (finding #1, v7). It carries the instance's
1062
+ // EXACT hoisted names (round-tripping a name derived from an arbitrary spec field) and is
1063
+ // the ONLY per-instance proof.
1064
+ const record = readHoistedNamespacesRecord(item.metadata?.annotations?.[HOISTED_NAMESPACES_ANNOTATION]);
1065
+ if (record.status === 'present') {
1066
+ // A VALID record — including an explicit empty `[]` — is per-instance proof: this
1067
+ // instance hoists exactly these namespaces (possibly none). Resolved; do NOT fail closed.
1068
+ for (const ns of record.names)
1069
+ result.add(ns);
1070
+ continue;
1071
+ }
1072
+ // MISSING or MALFORMED record → NO per-instance exact record. FAIL CLOSED (finding #1, v7). We do
1073
+ // NOT fall back to the RGD-wide `created-by-rgd` set: it is not per-instance proof — a
1074
+ // DIFFERENT (modern) instance could have stamped it, so a non-empty set would MASK this
1075
+ // legacy instance whose own — possibly different — owned namespace is then invisible and
1076
+ // pruned by the hoist. Nor do we approximate from metadata.namespace / spec.namespace.
1077
+ // Refusing the upgrade until the legacy instance is migrated/annotated is the intended,
1078
+ // safe transition cost — refusing beats silently pruning an unseen namespace.
1079
+ throw new TypeKroError(`Pre-hoist safety check found an existing instance of RGD "${this.rgdName}" with no ` +
1080
+ `per-instance namespace record — its CR carries no "${HOISTED_NAMESPACES_ANNOTATION}" ` +
1081
+ `annotation. The RGD-wide "${NAMESPACE_OWNER_ANNOTATION}=${this.rgdName}" set is NOT ` +
1082
+ `per-instance proof (another instance could have stamped it), so this instance's own ` +
1083
+ `owned namespace could be pruned by the hoist unseen. Refusing to deploy: migrate it ` +
1084
+ `(redeploy the instance with a current TypeKro so its namespaces are recorded) and retry.`, 'PRE_HOIST_LEGACY_INSTANCE_UNRESOLVABLE', { composition: this.name, rgdName: this.rgdName, mode: 'kro' });
1085
+ }
1086
+ return result;
1087
+ }
656
1088
  /**
657
1089
  * Get all deployed instances
658
1090
  */
@@ -671,11 +1103,15 @@ export class KroResourceFactoryImpl {
671
1103
  this.discoveredPlural = await this.lookupCRDPlural();
672
1104
  }
673
1105
  const plural = this.discoveredPlural ?? pluralizeKind(this.schemaDefinition.kind);
674
- // In the new API, methods take request objects and return objects directly
1106
+ // The instance CR lives in the namespace {@link resolveInstanceNamespace}
1107
+ // resolves to — the SAME shared resolver `deploy()`/`toYaml()` use, so a
1108
+ // create in namespace X is always listed in namespace X (finding #2). No
1109
+ // per-call spec is available here, so it resolves the factory default
1110
+ // (`instanceNamespace` override ?? factory namespace).
675
1111
  const listResponse = await customApi.listNamespacedCustomObject({
676
1112
  group: this.getSchemaGroup(),
677
1113
  version,
678
- namespace: this.namespace,
1114
+ namespace: this.resolveInstanceNamespace(),
679
1115
  plural,
680
1116
  });
681
1117
  const listResult = listResponse;
@@ -747,155 +1183,260 @@ export class KroResourceFactoryImpl {
747
1183
  if (opts?.scopes?.length) {
748
1184
  throw new TypeKroError('Scope-filtered deletion is not supported in KRO mode. KRO manages resource lifecycle via its own controller. Use direct mode for scope-filtered deletes.', 'UNSUPPORTED_OPTION', { scopes: opts.scopes, instanceName: name, mode: 'kro' });
749
1185
  }
750
- const kubeConfig = this.getKubeConfig();
751
- const k8sApi = createBunCompatibleKubernetesObjectApi(kubeConfig);
1186
+ const k8sApi = this.createKubernetesObjectApi();
1187
+ // ONE gating deletion mechanism for the whole teardown (finding #1): the engine's
1188
+ // rollback manager, whose `deleteResourceAndWait` deletes then polls to a REAL 404
1189
+ // and THROWS DeploymentTimeoutError on timeout — never a silent return that lets a
1190
+ // later step run behind a still-Terminating resource.
1191
+ const rollback = createRollbackManager(k8sApi);
1192
+ const timeout = this.factoryOptions.timeout ?? 300000;
752
1193
  const apiVersion = this.getInstanceApiVersion();
753
- // Tracks whether the CR was confirmed 404 by the poll loop. Used
754
- // later to decide whether to tear down the RGD/CRD or preserve
755
- // them for KRO to continue finalizer processing in the background.
756
- let instanceDeleted = false;
757
- let deletionTimedOut = false;
1194
+ // The CR lives in the namespace {@link resolveInstanceNamespace} resolves to the
1195
+ // SAME shared resolver `deploy()` created it with (finding #2), so we never look in
1196
+ // the wrong namespace and mistake a 404 there for "already gone".
1197
+ const instanceNamespace = this.resolveInstanceNamespace();
1198
+ // Capture the CR's durable RECORD of which namespaces THIS instance hoisted BEFORE
1199
+ // deleting it (finding #4). deleteInstance has no caller spec, so the live CR is the
1200
+ // only source of the declared set. PREFER the recorded `typekro.io/hoisted-namespaces`
1201
+ // annotation (stamped at deploy time) — it round-trips a name derived from an
1202
+ // arbitrary spec field (e.g. spec.targetNamespace) EXACTLY. Fall back to re-deriving
1203
+ // from the CR's spec for instances deployed before the annotation existed (the
1204
+ // imperative path CAN re-execute the composition). If the CR is already gone (404) or
1205
+ // unreadable, we cannot reconstruct it, so NO namespace becomes a delete candidate
1206
+ // (fail-safe: an owned-but-unprovable namespace is kept).
1207
+ let crSpec;
1208
+ let recordedHoistedNames = [];
758
1209
  try {
759
- // Delete the instance. KRO's controller processes kro.run/finalizer,
760
- // which does graph-based deletion of all child resources.
761
- await k8sApi.delete({
1210
+ const live = (await k8sApi.read({
762
1211
  apiVersion,
763
1212
  kind: this.schemaDefinition.kind,
764
- metadata: {
765
- name,
766
- namespace: this.namespace,
767
- },
768
- });
769
- // Wait for KRO to finish cleanup (finalizer processing).
770
- // KRO needs the RGD to exist during this phase — the caller must
771
- // not delete the RGD until deleteInstance completes.
772
- const timeout = this.factoryOptions.timeout ?? 300000;
773
- const startTime = Date.now();
774
- while (Date.now() - startTime < timeout) {
775
- try {
776
- await k8sApi.read({
777
- apiVersion,
778
- kind: this.schemaDefinition.kind,
779
- metadata: { name, namespace: this.namespace },
780
- });
781
- // Still exists — KRO is processing finalizer
782
- await new Promise((r) => setTimeout(r, 2000));
783
- }
784
- catch (pollError) {
785
- const pollK8sError = pollError;
786
- const errorCode = pollK8sError.statusCode ?? pollK8sError.code ?? pollK8sError.body?.code;
787
- if (errorCode === 404) {
788
- instanceDeleted = true;
789
- break;
790
- }
791
- // Non-404 error (permissions, server error) — log and retry
792
- this.logger.debug('Deletion poll error (retrying)', {
793
- name,
794
- errorCode,
795
- });
796
- await new Promise((r) => setTimeout(r, 2000));
797
- }
798
- }
799
- if (!instanceDeleted) {
800
- // KRO is still processing the finalizer. Treat the stuck instance as
801
- // unsafe to clean up, so the RGD/CRD are preserved.
802
- // Deleting the RGD while KRO is mid-finalizer would orphan cleanup.
803
- deletionTimedOut = true;
804
- this.logger.warn('Instance deletion still in progress after timeout', {
805
- name,
806
- timeout,
807
- elapsed: Date.now() - startTime,
808
- hint: 'KRO finalizer processing continues in the background. The RGD will be preserved.',
809
- });
1213
+ metadata: { name, namespace: instanceNamespace },
1214
+ }));
1215
+ crSpec = live.spec;
1216
+ recordedHoistedNames = parseHoistedNamespacesAnnotation(live.metadata?.annotations?.[HOISTED_NAMESPACES_ANNOTATION]);
1217
+ }
1218
+ catch (readError) {
1219
+ const k8sErr = readError;
1220
+ const code = k8sErr.statusCode ?? k8sErr.code ?? k8sErr.body?.code;
1221
+ if (code !== 404) {
1222
+ this.logger.debug('Could not read instance CR before delete (declared-namespace record unavailable)', { name, error: ensureError(readError).message });
810
1223
  }
811
1224
  }
1225
+ const declaredHoistedNamespaceNames = recordedHoistedNames.length > 0
1226
+ ? recordedHoistedNames
1227
+ : crSpec
1228
+ ? [...this.concreteHoistedNamespaces(crSpec).keys()]
1229
+ : [];
1230
+ // 1. Delete the CR and GATE on its 404 — KRO cleared `kro.run/finalizer` after
1231
+ // graph-deleting every child (findings #1 + #2). On timeout the gate THROWS, so we
1232
+ // NEVER proceed to delete the RGD while KRO is mid-finalizer (which would orphan
1233
+ // cleanup). A pre-existing 404 on delete is treated as already-deleted.
1234
+ let instanceDeleted = false;
1235
+ try {
1236
+ await rollback.deleteResourceAndWait({ apiVersion, kind: this.schemaDefinition.kind, name, namespace: instanceNamespace }, { timeout });
1237
+ instanceDeleted = true;
1238
+ }
812
1239
  catch (error) {
813
- const k8sError = error;
814
- const errorCode = k8sError.statusCode ?? k8sError.code ?? k8sError.body?.code;
815
- if (errorCode === 404) {
816
- instanceDeleted = true;
1240
+ if (error instanceof DeploymentTimeoutError) {
1241
+ // KRO is still processing the finalizer. The RGD/CRD/namespace are LEFT ALONE
1242
+ // (we throw before reaching them) so KRO can finish cleanup in the background.
1243
+ throw new CRDInstanceError(`KRO instance ${name} deletion did not complete within ${timeout}ms`, this.schemaDefinition.apiVersion, this.schemaDefinition.kind, name, 'deletion', ensureError(error));
817
1244
  }
818
- else {
819
- throw new CRDInstanceError(`Failed to delete instance ${name}: ${k8sError.message || String(error)}`, this.schemaDefinition.apiVersion, this.schemaDefinition.kind, name, 'deletion', ensureError(error));
820
- }
821
- }
822
- if (deletionTimedOut) {
823
- throw new CRDInstanceError(`KRO instance ${name} deletion did not complete within ${this.factoryOptions.timeout ?? 300000}ms`, this.schemaDefinition.apiVersion, this.schemaDefinition.kind, name, 'deletion');
824
- }
825
- // Only delete the RGD and CRD if no other instances remain. Multiple
826
- // instances can share one RGD deleting it would break the others.
827
- // The decision is a pure function of (listed instances, target name,
828
- // instanceDeleted flag) see {@link shouldPreserveRgd} for the rules.
829
- let hasRemainingInstances = false;
1245
+ throw new CRDInstanceError(`Failed to delete instance ${name}: ${ensureError(error).message}`, this.schemaDefinition.apiVersion, this.schemaDefinition.kind, name, 'deletion', ensureError(error));
1246
+ }
1247
+ // 2. LIST REMAINING INSTANCES FIRST — BEFORE deleting any namespace (finding #2, v7).
1248
+ // This instance's per-instance namespace cleanup below must EXCLUDE any namespace a
1249
+ // REMAINING instance still records: because ownership is RGD-wide, deleting this instance
1250
+ // could otherwise remove an empty/default-only namespace that another remaining instance
1251
+ // shares (its workloads may momentarily leave it empty), and the later RGD-preserve check
1252
+ // cannot RESTORE a deleted namespace. So we list once, up front, and use the result for
1253
+ // BOTH the shared-namespace exclusion AND the RGD-share decision. A list FAILURE is
1254
+ // fail-closed on BOTH: we can compute NEITHER the exclusion nor the share decision, so we
1255
+ // delete NO namespace and preserve the RGD/CRD (a retry completes teardown once the list
1256
+ // is readable).
1257
+ let remainingItems;
830
1258
  try {
831
- const instances = await this.listInstancesForCleanup();
832
- hasRemainingInstances = shouldPreserveRgd(instances, name, instanceDeleted, this.namespace);
1259
+ remainingItems = await this.listInstancesForCleanup();
833
1260
  }
834
1261
  catch (listError) {
835
- // Can't list instances — could be CRD gone (safe) or transient error
836
- // (unsafe to delete RGD). Default to preserving the RGD to avoid
837
- // breaking other instances that might still be using it.
838
- this.logger.warn('Cannot list instances to check for shared RGD preserving RGD', {
839
- rgdName: this.rgdName,
840
- error: ensureError(listError).message,
841
- });
842
- hasRemainingInstances = true;
843
- }
844
- if (!hasRemainingInstances) {
845
- // Prove the generated CRD can be cleaned up before deleting the RGD.
846
- // If plural discovery is unavailable, preserving both avoids orphaning
847
- // the generated CRD without its owning RGD.
848
- const crdPlural = await this.requireCRDPluralForCleanup();
849
- // Delete the RGD after the instance is gone.
850
- try {
851
- await k8sApi.delete({
852
- apiVersion: 'kro.run/v1alpha1',
853
- kind: 'ResourceGraphDefinition',
854
- metadata: { name: this.rgdName },
855
- });
856
- this.logger.debug('RGD deleted', { rgdName: this.rgdName });
857
- }
858
- catch (error) {
859
- const k8sErr = error;
860
- const errorCode = k8sErr.statusCode ?? k8sErr.code ?? k8sErr.body?.code;
861
- if (errorCode !== 404) {
862
- this.logger.warn('RGD cleanup failed', {
863
- rgdName: this.rgdName,
864
- error: ensureError(error).message,
865
- });
866
- throw error;
1262
+ this.logger.warn('Cannot list instances — preserving ALL namespaces + the RGD/CRD (fail closed; retry to complete)', { rgdName: this.rgdName, error: ensureError(listError).message });
1263
+ return;
1264
+ }
1265
+ // The decision to keep the RGD is a pure function of (listed instances, target name,
1266
+ // instanceDeleted flag) — see {@link shouldPreserveRgd} for the rules.
1267
+ const hasRemainingInstances = shouldPreserveRgd(remainingItems, name, instanceDeleted, instanceNamespace);
1268
+ // The remaining instances (this one filtered out) — the SAME filter shouldPreserveRgd
1269
+ // applies — whose recorded namespaces this instance's cleanup must PRESERVE.
1270
+ const remainingInstances = instanceDeleted
1271
+ ? remainingItems.filter((i) => {
1272
+ if (i.metadata?.name !== name)
1273
+ return true;
1274
+ if (!instanceNamespace)
1275
+ return false;
1276
+ return i.metadata?.namespace !== instanceNamespace;
1277
+ })
1278
+ : remainingItems;
1279
+ // 3. Delete THIS instance's OWN hoisted namespace(s) — EXCLUDING any a remaining instance
1280
+ // still records (finding #2, v7). Deletable = this instance's declared namespaces MINUS the
1281
+ // UNION of every remaining instance's recorded namespaces. This preserves v5's "each
1282
+ // instance cleans its own namespace even when non-last" (a NON-last instance's exclusive
1283
+ // namespace is still cleaned here, never leaked) while it also EXCLUDES a namespace a
1284
+ // remaining instance shares. Cleanup stays ownership-PRIMARY (`ownedByRgd`: a namespace only
1285
+ // this RGD's create stamped) + emptiness-secondary (a namespace another stack still occupies
1286
+ // is RETAINED) via deleteNamespaceIfEmpty. The names come from the CR's recorded
1287
+ // `typekro.io/hoisted-namespaces` annotation (finding #4), so an arbitrary-field name
1288
+ // round-trips cross-process. Namespace-before-RGD/CRD is safe: the RGD + generated CRD stay
1289
+ // HEALTHY/Active during ns termination, so the namespace controller confirms emptiness
1290
+ // INSTANTLY (never terminating against a *Terminating* CRD, upstream kro #1171). typekro
1291
+ // never puts a Namespace in the RGD, so KRO's finalizer never touched these — we own their
1292
+ // teardown; the delete is gated on a real 404 via the SAME primitive (throws on timeout).
1293
+ //
1294
+ // FAIL CLOSED: if ANY remaining instance's record cannot be resolved exactly (no
1295
+ // `hoisted-namespaces` annotation on an RGD that DOES hoist), the exclusion is uncomputable
1296
+ // → preserve ALL of this instance's namespaces (delete NONE), since any of them could be one
1297
+ // the unresolved instance still needs.
1298
+ if (declaredHoistedNamespaceNames.length > 0) {
1299
+ const preserved = new Set();
1300
+ let allRemainingResolvable = true;
1301
+ for (const item of remainingInstances) {
1302
+ const record = readHoistedNamespacesRecord(item.metadata?.annotations?.[HOISTED_NAMESPACES_ANNOTATION]);
1303
+ if (record.status !== 'present') {
1304
+ // MISSING or MALFORMED — this remaining instance's namespaces are uncomputable.
1305
+ allRemainingResolvable = false;
1306
+ continue;
867
1307
  }
1308
+ // PRESENT (including a valid empty []) is RESOLVED: contribute its namespaces (possibly
1309
+ // none). A valid [] no longer forces the fail-closed preserve-everything path (P2).
1310
+ for (const ns of record.names)
1311
+ preserved.add(ns);
868
1312
  }
869
- // Delete the CRD that KRO created from the RGD. KRO's default config
870
- // has allowCRDDeletion=false, so it won't clean up the CRD when the
871
- // RGD is deleted. Prefer the server-discovered plural over the
872
- // heuristic fallback so already-plural kinds clean up correctly.
873
- const crdName = `${crdPlural}.${this.getSchemaGroup()}`;
874
- try {
875
- await k8sApi.delete({
876
- apiVersion: 'apiextensions.k8s.io/v1',
877
- kind: 'CustomResourceDefinition',
878
- metadata: { name: crdName },
879
- });
880
- this.logger.debug('CRD deleted', { crdName });
1313
+ if (!allRemainingResolvable) {
1314
+ this.logger.warn("A remaining instance has no resolvable namespace record preserving ALL of this instance's " +
1315
+ 'namespaces (fail closed) to avoid deleting one a remaining instance shares', { rgdName: this.rgdName, instance: name });
881
1316
  }
882
- catch (error) {
883
- const k8sErr = error;
884
- const errorCode = k8sErr.statusCode ?? k8sErr.code ?? k8sErr.body?.code;
885
- if (errorCode !== 404) {
886
- this.logger.warn('CRD cleanup failed', { crdName, error: ensureError(error).message });
887
- throw error;
1317
+ else {
1318
+ for (const ns of declaredHoistedNamespaceNames) {
1319
+ if (preserved.has(ns)) {
1320
+ this.logger.debug('Namespace recorded by a remaining instance — preserving', {
1321
+ namespace: ns,
1322
+ rgdName: this.rgdName,
1323
+ });
1324
+ continue;
1325
+ }
1326
+ await deleteNamespaceIfEmpty(this.getKubeConfig(), ns, {
1327
+ logger: this.logger,
1328
+ // Reuse the SAME (mockable) object API so discovery/delete is unit-testable.
1329
+ k8sApi,
1330
+ ownedByRgd: this.rgdName,
1331
+ timeoutMs: timeout,
1332
+ context: { rgdName: this.rgdName },
1333
+ });
888
1334
  }
889
1335
  }
890
1336
  }
891
- else {
1337
+ // Only delete the RGD and CRD if no other instances remain. Multiple instances can share
1338
+ // one RGD — deleting it would break the others.
1339
+ if (hasRemainingInstances) {
892
1340
  this.logger.debug('Skipping RGD/CRD deletion — other instances still exist', {
893
1341
  rgdName: this.rgdName,
894
1342
  });
1343
+ return;
1344
+ }
1345
+ // 2b. RETRY-SAFE owned-namespace sweep (finding #2). The per-instance cleanup above
1346
+ // reads the names off the CR's `typekro.io/hoisted-namespaces` annotation — but that
1347
+ // record dies WITH the CR, so a retry AFTER an earlier attempt already deleted the CR
1348
+ // (crash / partial teardown) would find the CR 404, read NO names, and skip cleanup.
1349
+ // Since this is the LAST instance, find EVERY namespace this RGD created via the
1350
+ // DURABLE `typekro.io/created-by-rgd` namespace annotation (survives the CR) and clean
1351
+ // each — catching any namespace leaked by an interrupted teardown. The names above may
1352
+ // overlap; `deleteNamespaceIfEmpty` is idempotent (a 404 is a no-op). A LIST failure
1353
+ // means we cannot confirm cleanup, so PRESERVE the RGD/CRD and return (a later retry
1354
+ // completes it) — deleting the definitions now could orphan a leaked namespace.
1355
+ let ownedNamespaces;
1356
+ try {
1357
+ ownedNamespaces = await listNamespacesOwnedByRgd(this.getKubeConfig(), this.rgdName, {
1358
+ k8sApi,
1359
+ logger: this.logger,
1360
+ });
1361
+ }
1362
+ catch (listError) {
1363
+ this.logger.warn('Cannot list owned namespaces to confirm cleanup — preserving RGD/CRD (retry to complete)', { rgdName: this.rgdName, error: ensureError(listError).message });
1364
+ return;
1365
+ }
1366
+ for (const ns of ownedNamespaces) {
1367
+ await deleteNamespaceIfEmpty(this.getKubeConfig(), ns, {
1368
+ logger: this.logger,
1369
+ k8sApi,
1370
+ ownedByRgd: this.rgdName,
1371
+ timeoutMs: timeout,
1372
+ context: { rgdName: this.rgdName },
1373
+ });
1374
+ }
1375
+ // 2c. CONFIRM owned-namespace cleanup BEFORE removing the definitions (finding #2): do
1376
+ // NOT delete the RGD/CRD while any owned namespace still exists or cannot be confirmed
1377
+ // gone. `deleteNamespaceIfEmpty` above RETAINS (does not delete) a namespace another
1378
+ // stack still occupies or one it could not read — those legitimately remain here, so we
1379
+ // keep the RGD/CRD rather than orphan a still-owned namespace from its definition. Only
1380
+ // once NO created-by-rgd namespace remains do we proceed to tear the definitions down.
1381
+ let remainingOwned;
1382
+ try {
1383
+ remainingOwned = await listNamespacesOwnedByRgd(this.getKubeConfig(), this.rgdName, {
1384
+ k8sApi,
1385
+ logger: this.logger,
1386
+ });
1387
+ }
1388
+ catch (listError) {
1389
+ this.logger.warn('Cannot confirm owned-namespace cleanup — preserving RGD/CRD (retry to complete)', { rgdName: this.rgdName, error: ensureError(listError).message });
1390
+ return;
1391
+ }
1392
+ if (remainingOwned.length > 0) {
1393
+ this.logger.info('Owned namespace(s) still present after cleanup — preserving RGD/CRD until they are gone', { rgdName: this.rgdName, remaining: remainingOwned });
1394
+ return;
1395
+ }
1396
+ // 3. Delete the RGD and GATE on its 404 (findings #1 + #2). The RGD carries a KRO
1397
+ // finalizer; while KRO processes it, KRO's per-RGD dynamic controller is still
1398
+ // WATCHING the generated CRD's resources. Waiting for the RGD 404 first lets KRO
1399
+ // tear its controller down cleanly. The gate THROWS on timeout (no silent proceed).
1400
+ await rollback.deleteResourceAndWait({ apiVersion: 'kro.run/v1alpha1', kind: 'ResourceGraphDefinition', name: this.rgdName }, { timeout });
1401
+ this.logger.debug('RGD deleted and fully gone', { rgdName: this.rgdName });
1402
+ // 4. Delete the generated CRD LAST — after the CR (404), RGD (404), and the owned
1403
+ // namespace are all gone (reverse-topo: CRDs die last). This step is BEST-EFFORT and
1404
+ // NON-FATAL: KRO's default config has allowCRDDeletion=false so KRO never deletes it,
1405
+ // and the apiextensions `customresourcecleanup` finalizer can stall for minutes even
1406
+ // with zero instances (upstream kro #1171). By now the namespace is already cleanly
1407
+ // gone, so a slow CRD teardown blocks NOTHING — we initiate the delete and wait
1408
+ // generously, but a timeout/failure is LOGGED, never thrown: it must not undo an
1409
+ // otherwise-successful teardown. A dangling (Terminating or leftover) CRD is
1410
+ // harmless; an operator may GC it out-of-band. See docs/advanced/migration.md.
1411
+ await this.deleteGeneratedCrdBestEffort(rollback, timeout);
1412
+ }
1413
+ /**
1414
+ * BEST-EFFORT deletion of this factory's generated CRD, the LAST teardown step (after
1415
+ * the owned namespace is gone). Never throws: a slow/stuck apiextensions cleanup
1416
+ * finalizer (upstream kro #1171) must not fail an otherwise-complete teardown, and by
1417
+ * this point nothing depends on the CRD being gone. Logs the outcome.
1418
+ */
1419
+ async deleteGeneratedCrdBestEffort(rollback, timeout) {
1420
+ // Prefer the already-discovered plural; else the non-throwing lookup (this is
1421
+ // best-effort): if we cannot determine the CRD name, log and skip rather than fail
1422
+ // the completed teardown.
1423
+ const crdPlural = this.discoveredPlural ?? (await this.lookupCRDPlural());
1424
+ if (!crdPlural) {
1425
+ this.logger.debug('Skipping best-effort CRD delete — plural undiscoverable (teardown already complete)', {
1426
+ rgdName: this.rgdName,
1427
+ });
1428
+ return;
1429
+ }
1430
+ const crdName = `${crdPlural}.${this.getSchemaGroup()}`;
1431
+ try {
1432
+ await rollback.deleteResourceAndWait({ apiVersion: 'apiextensions.k8s.io/v1', kind: 'CustomResourceDefinition', name: crdName }, { timeout });
1433
+ this.logger.debug('Generated CRD deleted and fully gone (last teardown step)', { crdName });
1434
+ }
1435
+ catch (error) {
1436
+ // NON-FATAL: the namespace is already gone; a lingering/Terminating CRD is
1437
+ // harmless (kro #1171). Log and return success.
1438
+ this.logger.warn('Best-effort generated-CRD delete did not complete — leaving it (harmless; GC out-of-band)', { crdName, error: ensureError(error).message });
895
1439
  }
896
- // Namespaces are resources in the composition's dependency graph.
897
- // KRO's finalizer processing handles deleting all child resources
898
- // (including Namespaces) via its applyset — no manual cleanup needed.
899
1440
  }
900
1441
  /**
901
1442
  * Emit this factory's KRO deployment as declarative alchemy **v2** resources — the v2
@@ -924,10 +1465,32 @@ export class KroResourceFactoryImpl {
924
1465
  name: this.name,
925
1466
  });
926
1467
  this.assertInstanceNamespaceOwnershipSafe(spec);
1468
+ const instanceNamespace = this.resolveInstanceNamespace(spec);
927
1469
  const kubeConfigOptions = this.extractKubeConfigOptionsForAlchemy();
928
- const kroDeletion = this.createAlchemyKroDeletionOptions();
1470
+ const kroDeletion = this.createAlchemyKroDeletionOptions(instanceNamespace);
929
1471
  const prerequisiteDeclarations = this.prerequisiteAlchemyDeclarations(kubeConfigOptions);
930
1472
  const prerequisiteIds = prerequisiteDeclarations.map((decl) => decl.id);
1473
+ // Every Namespace the composition owns is HOISTED out of the RGD graph and
1474
+ // emitted here instead — before the RGD + instance and OUTSIDE the graph, so the
1475
+ // CR always has a namespace to land in and KRO never garbage-collects it (which
1476
+ // would strand the instance's finalizer). Teardown is EMPTY-GATED (findings #3 +
1477
+ // #4): alchemy's reverse-topo teardown runs the namespace's delete AFTER the RGD +
1478
+ // instance (both `dependsOn` it), and the instance's delete waits for its
1479
+ // `kro.run/finalizer` to clear (KRO graph-deletes all children) — so by then
1480
+ // everything THIS instance owned has drained (finding #1's ordering, via the
1481
+ // dependency graph + instance-drain wait). The namespace is then deleted ONLY if
1482
+ // empty and RETAINED if another stack/user still has resources there. This is the
1483
+ // same for the instance's OWN namespace and a SHARED one — deduped by name, so
1484
+ // multiple stacks share ONE declaration and none deletes it while another still
1485
+ // occupies it (no cross-stack refcount needed).
1486
+ const instanceNamespaceDeclarations = [];
1487
+ const instanceNamespaceIds = [];
1488
+ for (const [hoistedNs, original] of this.concreteHoistedNamespaces(spec)) {
1489
+ const decl = this.hoistedNamespaceAlchemyDeclaration(hoistedNs, this.buildHoistedNamespaceResource(hoistedNs, original, spec), kubeConfigOptions, prerequisiteIds);
1490
+ instanceNamespaceDeclarations.push(decl);
1491
+ instanceNamespaceIds.push(decl.id);
1492
+ }
1493
+ const leadingIds = [...prerequisiteIds, ...instanceNamespaceIds];
931
1494
  // 0. Singleton owners. The imperative `deploy()` ensures shared singleton owners (each its own
932
1495
  // RGD + instance, in its registry namespace) via `ensureSingletonOwners` BEFORE the main
933
1496
  // instance; emit them as declarations too so the declarative path has the same boundaries.
@@ -967,14 +1530,14 @@ export class KroResourceFactoryImpl {
967
1530
  // RGD would then carry an object where a string belongs — KRO rejects the whole graph
968
1531
  // (`GraphAccepted=False: expected string type ..., got object`) and it never reconciles. Matching
969
1532
  // the load schema to the dump makes the round-trip lossless (the scalar stays the string it was).
970
- const rgdManifest = yaml.load(this.buildRgdYaml(), { schema: yaml.JSON_SCHEMA });
1533
+ const rgdManifest = yaml.load(this.buildRgdYaml(spec), { schema: yaml.JSON_SCHEMA });
971
1534
  const rgdFactory = this.rgdProvider ??
972
1535
  (await import('../../factories/kro/resource-graph-definition.js')).resourceGraphDefinition;
973
1536
  const rgdEnhanced = rgdFactory(rgdManifest);
974
1537
  const rgdId = createAlchemyResourceId(rgdEnhanced, this.namespace);
975
1538
  const rgdDeclaration = {
976
1539
  id: rgdId,
977
- dependsOn: prerequisiteIds,
1540
+ dependsOn: leadingIds,
978
1541
  props: {
979
1542
  resource: rgdEnhanced,
980
1543
  namespace: this.namespace,
@@ -997,13 +1560,13 @@ export class KroResourceFactoryImpl {
997
1560
  // Cast: a plain KubernetesResource is fine — the alchemy path only reads kind/metadata.
998
1561
  const crdAsEnhanced = crdInstanceManifest;
999
1562
  const instanceDeclaration = {
1000
- id: createAlchemyResourceId(crdAsEnhanced, this.namespace),
1563
+ id: this.instanceAlchemyId(crdAsEnhanced),
1001
1564
  // Wait for the RGD's CRD AND for any singleton owners (mirrors `ensureSingletonOwners` running
1002
1565
  // before the main deploy), so the instance applies only once its dependencies exist.
1003
- dependsOn: [rgdId, ...singletonInstanceIds],
1566
+ dependsOn: [rgdId, ...singletonInstanceIds, ...instanceNamespaceIds],
1004
1567
  props: {
1005
1568
  resource: crdAsEnhanced,
1006
- namespace: this.namespace,
1569
+ namespace: instanceNamespace,
1007
1570
  deploymentStrategy: 'kro',
1008
1571
  kubeConfigOptions,
1009
1572
  kroDeletion,
@@ -1019,22 +1582,43 @@ export class KroResourceFactoryImpl {
1019
1582
  };
1020
1583
  return [
1021
1584
  ...prerequisiteDeclarations,
1585
+ ...instanceNamespaceDeclarations,
1022
1586
  ...singletonDeclarations,
1023
1587
  rgdDeclaration,
1024
1588
  instanceDeclaration,
1025
1589
  ];
1026
1590
  }
1591
+ /**
1592
+ * The alchemy resource id for a KRO instance CR — the LEGACY, namespace-agnostic
1593
+ * kind+name id (finding #1).
1594
+ *
1595
+ * This deliberately does NOT fold the namespace into the id. An earlier revision
1596
+ * appended a namespace hash to distinguish same-named instances placed in
1597
+ * different namespaces, but that changed every existing instance's id versus the
1598
+ * released v0.26.0, so alchemy would see remove+replace and could tear down
1599
+ * (delete) the live CR on upgrade. Reverting to the legacy id keeps existing
1600
+ * alchemy state identity stable.
1601
+ *
1602
+ * Consequence to be honest about: within ONE alchemy stack, two instances collide
1603
+ * iff they share this kind+name id — regardless of their k8s namespace. A
1604
+ * different factory namespace does NOT automatically yield a different alchemy
1605
+ * scope. To keep same-named instances (e.g. `analytics` in dev vs prod) separate,
1606
+ * the CALLER must materialize them in SEPARATE alchemy stacks/scopes.
1607
+ */
1608
+ instanceAlchemyId(resource) {
1609
+ return createAlchemyResourceId(resource);
1610
+ }
1027
1611
  /** Serialize the factory's cluster connection so an alchemy resource can reconnect after rehydration. */
1028
1612
  extractKubeConfigOptionsForAlchemy() {
1029
1613
  return extractSerializableKubeConfigOptions(this.getKubeConfig(), this.factoryOptions.skipTLSVerify === true ? true : undefined);
1030
1614
  }
1031
1615
  /** Build the finalizer-safe, shared-RGD-aware deletion metadata for this factory's instances. */
1032
- createAlchemyKroDeletionOptions() {
1616
+ createAlchemyKroDeletionOptions(instanceNamespace = this.namespace) {
1033
1617
  return {
1034
1618
  apiVersion: this.schemaDefinition.apiVersion,
1035
1619
  kind: this.schemaDefinition.kind,
1036
1620
  ...(this.schemaDefinition.group && { group: this.schemaDefinition.group }),
1037
- namespace: this.namespace,
1621
+ namespace: instanceNamespace,
1038
1622
  rgdName: this.rgdName,
1039
1623
  ...(this.discoveredPlural && { plural: this.discoveredPlural }),
1040
1624
  timeout: this.factoryOptions.timeout ?? 300000,
@@ -1129,8 +1713,16 @@ export class KroResourceFactoryImpl {
1129
1713
  // Shared singleton owner instances are created by `deploy()`; for the
1130
1714
  // GitOps path we emit them alongside the consuming instance, deps-first,
1131
1715
  // so the consuming RGD's externalRef resolves. No-op without singletons.
1716
+ // Any owned workload Namespace hoisted out of the RGD graph leads, so the CR
1717
+ // always has a namespace to apply into and KRO never garbage-collects it.
1132
1718
  const ownerYamls = singletonOwnerInstanceYamls(this.discoverSingletonDefinitions(spec));
1133
- return ownerYamls.length === 0 ? instanceYaml : joinYamlDocuments(ownerYamls, instanceYaml);
1719
+ const leadingInstanceYamls = [
1720
+ ...[...this.concreteHoistedNamespaces(spec)].map(([ns, original]) => this.hoistedNamespaceYaml(ns, original, spec)),
1721
+ ...ownerYamls,
1722
+ ];
1723
+ return leadingInstanceYamls.length === 0
1724
+ ? instanceYaml
1725
+ : joinYamlDocuments(leadingInstanceYamls, instanceYaml);
1134
1726
  }
1135
1727
  const rgdYaml = this.buildRgdYaml();
1136
1728
  const prerequisiteYamls = this.prerequisiteResourceYamls();
@@ -1148,13 +1740,39 @@ export class KroResourceFactoryImpl {
1148
1740
  * step — it's applied inline during ref-to-CEL conversion via
1149
1741
  * `SerializationContext.omitFields`, which `serializeResourceGraphToYaml`
1150
1742
  * populates from `kroSchema.__omitFields` automatically.
1743
+ *
1744
+ * The hoist decision is SPEC-INDEPENDENT (finding #4): the RGD is shared by every
1745
+ * instance of this factory, so its shape must be a stable structural property and
1746
+ * must NOT vary with a per-call spec. `spec` is accepted only for signature
1747
+ * symmetry with the callers and is deliberately NOT used to decide hoisting — see
1748
+ * {@link hoistedNamespaceRefs} / {@link resourcesForRgd}.
1151
1749
  */
1152
- buildRgdYaml() {
1750
+ buildRgdYaml(_spec) {
1153
1751
  if (this.factoryOptions.compositionAnalysis && !this.compositionAnalysisApplied) {
1154
1752
  this.compositionAnalysisApplied = true;
1155
1753
  applyAnalysisToResources(this.resources, this.factoryOptions.compositionAnalysis);
1156
1754
  }
1157
- const aspectResources = applyAspects(this.resources, {
1755
+ // Hoist EVERY owned Namespace OUT of the RGD graph (STRUCTURALLY, so the shared
1756
+ // RGD shape is identical for every instance) and rewrite any dangling reference
1757
+ // to it. typekro NEVER emits a Namespace into RGD YAML; each is applied as a
1758
+ // sibling (toYaml/toAlchemyResources/imperative deploy), so deleting the instance
1759
+ // can never garbage-collect the namespace holding its own finalizer.
1760
+ const hoistIds = this.hoistedNamespaceRefs(this.resources);
1761
+ const graphResources = this.resourcesForRgd(this.resources);
1762
+ // Rewrite references to a hoisted Namespace in the STATUS mappings too
1763
+ // (finding #6): a status CEL like `ns-${string(ownedNamespace.metadata.name)}`
1764
+ // would otherwise reference a resource removed from the graph and KRO would
1765
+ // reject the RGD. Each becomes the referenced Namespace's own name expression.
1766
+ const statusMappings = rewriteHoistedNamespaceRefsInValue(this.statusMappings, hoistIds);
1767
+ const nestedCel = rewriteHoistedNamespaceRefsInValue(this.getNestedStatusCel(), hoistIds);
1768
+ // #6 — REJECT (throw) HONESTLY, never silently drop: a status field whose only
1769
+ // reference was a hoisted Namespace becomes a schema-only expression
1770
+ // (`schema.spec.namespace`), which KRO status CEL cannot evaluate, so it cannot be
1771
+ // represented in the KRO status schema. Rather than ship a status API weaker than
1772
+ // the declared one (the old warn-and-drop), fail loudly naming the field(s).
1773
+ // Resource-derived sibling fields are unaffected.
1774
+ assertNoHoistWeakenedStatusFields(this.statusMappings, hoistIds, this.name);
1775
+ const aspectResources = applyAspects(graphResources, {
1158
1776
  mode: 'kro',
1159
1777
  aspects: this.factoryOptions.aspects ?? [],
1160
1778
  });
@@ -1164,10 +1782,9 @@ export class KroResourceFactoryImpl {
1164
1782
  if (isStrictCelDiagnosticsEnabled(this.factoryOptions)) {
1165
1783
  this.assertStatusCelReferencesKnownResources(aspectResources);
1166
1784
  }
1167
- const kroSchema = generateKroSchemaFromArktype(this.name, this.schemaDefinition, aspectResources, this.statusMappings, this.getNestedStatusCel());
1785
+ const kroSchema = generateKroSchemaFromArktype(this.name, this.schemaDefinition, aspectResources, statusMappings, nestedCel);
1168
1786
  // Attach nested status CEL mappings as non-enumerable property so
1169
1787
  // serializeResourceGraphToYaml can inline virtual composition IDs.
1170
- const nestedCel = this.getNestedStatusCel();
1171
1788
  if (nestedCel && Object.keys(nestedCel).length > 0) {
1172
1789
  Object.defineProperty(kroSchema, '__nestedStatusCel', {
1173
1790
  value: nestedCel,
@@ -1181,13 +1798,15 @@ export class KroResourceFactoryImpl {
1181
1798
  // identifiers: [schema]") and are hydrated client-side instead, like any
1182
1799
  // other static status field.
1183
1800
  const resourceIdList = Object.keys(aspectResources);
1184
- const nestedCel = this.getNestedStatusCel();
1185
1801
  const nestedCelForClassification = nestedCel && Object.keys(nestedCel).length > 0 ? nestedCel : undefined;
1186
1802
  for (const override of statusOverrides) {
1187
- if (isStaticExpression(override.celExpression, nestedCelForClassification, resourceIdList))
1803
+ // Rewrite any reference to a hoisted Namespace in the override expression
1804
+ // too (finding #6), so a status override never dangles at the removed id.
1805
+ const celExpression = rewriteHoistedNamespaceRefsInValue(override.celExpression, hoistIds);
1806
+ if (isStaticExpression(celExpression, nestedCelForClassification, resourceIdList))
1188
1807
  continue;
1189
1808
  kroSchema.status ??= {};
1190
- const yamlSafe = override.celExpression.replace(/"([^"\\]*)"/g, "'$1'");
1809
+ const yamlSafe = celExpression.replace(/"([^"\\]*)"/g, "'$1'");
1191
1810
  kroSchema.status[override.propertyPath] = yamlSafe;
1192
1811
  }
1193
1812
  }
@@ -1202,7 +1821,27 @@ export class KroResourceFactoryImpl {
1202
1821
  applyTernaryConditionalsToResources(aspectResources, kroSchema.__ternaryConditionals, kroSchema.__nestedStatusCel);
1203
1822
  }
1204
1823
  }
1205
- return serializeResourceGraphToYaml(this.rgdName, aspectResources, { namespace: this.namespace }, kroSchema);
1824
+ const rgdYaml = serializeResourceGraphToYaml(this.rgdName, aspectResources, { namespace: this.namespace }, kroSchema);
1825
+ // VALIDATE (finding #6): after hoisting, NO reference to any removed resource id
1826
+ // may remain anywhere in the emitted RGD. If one slipped through (a form the
1827
+ // rewrite didn't structurally cover), fail LOUDLY here rather than shipping an
1828
+ // RGD KRO will reject at runtime with a dangling `${...}` reference.
1829
+ this.assertNoDanglingHoistedReferences(rgdYaml, new Set(hoistIds.keys()));
1830
+ return rgdYaml;
1831
+ }
1832
+ /**
1833
+ * Assert the emitted RGD carries NO leftover reference to a hoisted (removed)
1834
+ * resource id — neither a CEL interpolation `${<id>.…}` nor a raw
1835
+ * `__KUBERNETES_REF_<id>_…` marker (finding #6). A negative lookbehind avoids
1836
+ * matching an id that is only a suffix of a longer identifier.
1837
+ */
1838
+ assertNoDanglingHoistedReferences(rgdYaml, hoistIds) {
1839
+ const dangling = findDanglingHoistedReference(rgdYaml, hoistIds);
1840
+ if (dangling !== undefined) {
1841
+ throw new ResourceGraphFactoryError(`Hoisting the owned Namespace left a dangling reference to removed resource "${dangling}" in the ` +
1842
+ 'emitted RGD. This would make KRO reject the graph. Report this as a typekro bug (the ' +
1843
+ 'reference form was not structurally rewritten).', this.name, 'deployment');
1844
+ }
1206
1845
  }
1207
1846
  /**
1208
1847
  * Strict CEL diagnostics: verify that every dynamic status CEL expression
@@ -1289,14 +1928,17 @@ export class KroResourceFactoryImpl {
1289
1928
  }
1290
1929
  }
1291
1930
  /**
1292
- * Ensure the ResourceGraphDefinition is deployed using DirectDeploymentEngine
1931
+ * Ensure the ResourceGraphDefinition is deployed using DirectDeploymentEngine.
1932
+ *
1933
+ * The `spec` (when the caller has one) lets the RGD hoist the composition's own
1934
+ * workload Namespace out of the graph with its schema-driven name resolved.
1293
1935
  */
1294
- async ensureRGDDeployed() {
1936
+ async ensureRGDDeployed(spec) {
1295
1937
  // Create DirectDeploymentEngine instance with KRO mode for CEL string generation
1296
1938
  const deploymentEngine = new DirectDeploymentEngine(this.getKubeConfig(), undefined, undefined, DeploymentMode.KRO);
1297
1939
  // Build the RGD YAML — shared with toYaml() so both call sites emit
1298
1940
  // identical post-processed output and share the single-apply guard.
1299
- const rgdYaml = this.buildRgdYaml();
1941
+ const rgdYaml = this.buildRgdYaml(spec);
1300
1942
  // Parse the YAML to get the RGD object. Use js-yaml with JSON_SCHEMA to MATCH the dump schema in
1301
1943
  // `serializeResourceGraphToYaml` (same fix as the alchemy path above — this is the imperative/direct
1302
1944
  // KRO deploy path). `k8s.loadAllYaml` uses js-yaml's DEFAULT (timestamp-aware) schema, which coerces an
@@ -1786,35 +2428,273 @@ export class KroResourceFactoryImpl {
1786
2428
  */
1787
2429
  createCustomResourceInstance(instanceName, spec, singletonSpecFingerprint) {
1788
2430
  const apiVersion = this.getInstanceApiVersion();
2431
+ // DURABLE RECORD (finding #4): stamp the CONCRETE hoisted-namespace names on the CR so
2432
+ // teardown + the pre-hoist guard read them back EXACTLY, without re-deriving from
2433
+ // metadata.namespace/spec.namespace. This is what lets a name derived from an ARBITRARY
2434
+ // spec field (e.g. spec.targetNamespace) round-trip. ALWAYS record the array — INCLUDING an
2435
+ // empty [] — so the pre-hoist guard can distinguish "this instance hoists zero namespaces"
2436
+ // (a valid, safe record) from a genuinely-legacy CR that predates the annotation (missing →
2437
+ // fail closed). Omitting [] made an ordinary namespace-less composition's 2nd deploy throw.
2438
+ const hoistedNamespaceNames = [...this.concreteHoistedNamespaces(spec).keys()];
2439
+ const annotations = {
2440
+ ...(singletonSpecFingerprint
2441
+ ? { 'typekro.io/singleton-spec-fingerprint': singletonSpecFingerprint }
2442
+ : {}),
2443
+ [HOISTED_NAMESPACES_ANNOTATION]: JSON.stringify(hoistedNamespaceNames),
2444
+ };
1789
2445
  return {
1790
2446
  apiVersion,
1791
2447
  kind: this.schemaDefinition.kind,
1792
2448
  metadata: {
1793
2449
  name: instanceName,
1794
- namespace: this.namespace,
2450
+ namespace: this.resolveInstanceNamespace(spec),
1795
2451
  labels: {
1796
2452
  'typekro.io/factory': this.name,
1797
2453
  'typekro.io/mode': this.mode,
1798
- 'typekro.io/rgd': this.rgdName,
2454
+ [INSTANCE_RGD_LABEL]: this.rgdName,
1799
2455
  },
1800
- ...(singletonSpecFingerprint
2456
+ ...(Object.keys(annotations).length > 0 ? { annotations } : {}),
2457
+ },
2458
+ spec,
2459
+ };
2460
+ }
2461
+ /**
2462
+ * Labels/annotations marking a hoisted workload Namespace as SHARED, RETAINED
2463
+ * infrastructure that must survive any single consumer's teardown/prune.
2464
+ *
2465
+ * Retention is declared for BOTH major GitOps reconcilers, not just Flux, so a
2466
+ * prune (or an Application deletion) by whichever tool manages a consuming app
2467
+ * can never delete this shared namespace (and with it every OTHER stack's
2468
+ * resources living inside it):
2469
+ * - `kustomize.toolkit.fluxcd.io/prune: disabled` — Flux Kustomize.
2470
+ * - `argocd.argoproj.io/sync-options: Prune=false,Delete=false` — Argo CD.
2471
+ * `Delete=false` is what survives an Argo Application DELETION, not merely a
2472
+ * sync-prune (`Prune=false` alone does NOT).
2473
+ * The alchemy path pairs these with a `retain` prop (see
2474
+ * {@link hoistedNamespaceAlchemyDeclaration}) that skips delete. For any other
2475
+ * GitOps tool, pre-create the workload namespace out-of-band.
2476
+ */
2477
+ static INSTANCE_NAMESPACE_METADATA = {
2478
+ labels: {
2479
+ 'app.kubernetes.io/managed-by': 'typekro',
2480
+ 'typekro.io/kro-instance-namespace': 'true',
2481
+ },
2482
+ annotations: {
2483
+ 'kustomize.toolkit.fluxcd.io/prune': 'disabled',
2484
+ 'argocd.argoproj.io/sync-options': 'Prune=false,Delete=false',
2485
+ },
2486
+ };
2487
+ /**
2488
+ * The collision-free singleton id for a hoisted workload Namespace, deduped by
2489
+ * the namespace NAME (finding #7). {@link toCamelCase} alone destroys separators
2490
+ * (`foo-bar` and `foo--bar` both camel-case to `fooBar`), so two DISTINCT
2491
+ * namespaces would collapse to one id — silently deduping unrelated namespaces
2492
+ * into a single retained resource. Appending a stable hash of the RAW name keeps
2493
+ * distinct names distinct while staying deterministic (same name → same id, so
2494
+ * the intended cross-factory dedup still holds).
2495
+ */
2496
+ static hoistedNamespaceId(workloadNamespace) {
2497
+ return `${toCamelCase(`kro-instance-namespace-${workloadNamespace}`)}${shortStableHash(workloadNamespace)}`;
2498
+ }
2499
+ /**
2500
+ * Resolve a single Namespace metadata VALUE (a label/annotation value) against
2501
+ * the concrete spec to its final string form (finding #8). Handles:
2502
+ * - plain strings (kept verbatim),
2503
+ * - numbers / booleans (stringified — a "non-string" declared value),
2504
+ * - schema-derived CEL expressions / KubernetesRefs (resolved to the concrete
2505
+ * value; re-execution already collapsed the schema ref, but this evaluates
2506
+ * it against the spec for robustness).
2507
+ * Returns `undefined` for a value that cannot be resolved to a string, so the
2508
+ * caller drops only genuinely-unresolvable entries rather than the whole class of
2509
+ * non-string / schema-derived metadata (the old string-only filter dropped them).
2510
+ */
2511
+ static resolveMetadataStringValue(value, spec) {
2512
+ // Metadata VALUES are unrestricted strings (finding #5): `Team_A`, free text with
2513
+ // spaces, etc. — resolve any CONCRETE value, without the DNS-label shape
2514
+ // restriction that `resolveNamespaceName` correctly applies to namespace NAMES.
2515
+ return resolveConcreteMetadataValue(value, spec);
2516
+ }
2517
+ /**
2518
+ * Merge the SHARED/RETAINED marker labels + annotations onto an ORIGINAL,
2519
+ * fully-concrete Namespace resource, PRESERVING the COMPLETE declared config
2520
+ * (findings #6 + #8): ALL labels — including Pod Security (`pod-security.kubernetes.io/*`)
2521
+ * AND non-string / schema-derived labels resolved against the spec — ALL
2522
+ * annotations, the remaining declarative ObjectMeta (`finalizers`,
2523
+ * `ownerReferences`), AND the Namespace `spec` (e.g. `spec.finalizers`). The
2524
+ * retention markers are added on top.
2525
+ */
2526
+ static mergedHoistedNamespaceMetadata(workloadNamespace, original, spec) {
2527
+ const originalMeta = (original.metadata ?? {});
2528
+ const resolveMap = (value) => {
2529
+ const out = {};
2530
+ for (const [key, val] of Object.entries(value ?? {})) {
2531
+ const resolved = KroResourceFactoryImpl.resolveMetadataStringValue(val, spec);
2532
+ if (resolved !== undefined)
2533
+ out[key] = resolved;
2534
+ }
2535
+ return out;
2536
+ };
2537
+ // `original` is an Enhanced PROXY: reading an UNDECLARED field returns a
2538
+ // KubernetesRef marker (a truthy function-like proxy), NOT undefined. Passing
2539
+ // such a proxy through to the k8s client serializer makes it try to iterate a
2540
+ // `{}`-looking value for an array-typed attribute (e.g. `spec.finalizers`) and
2541
+ // throw "{} is not iterable". So extract only the CONCRETE, own-enumerable
2542
+ // declared values via a JSON round-trip (drops proxies, ref-markers, symbols,
2543
+ // functions) and omit anything empty.
2544
+ const concretize = (value) => {
2545
+ if (value === undefined || value === null)
2546
+ return undefined;
2547
+ try {
2548
+ const cloned = JSON.parse(JSON.stringify(value));
2549
+ return cloned;
2550
+ }
2551
+ catch {
2552
+ return undefined;
2553
+ }
2554
+ };
2555
+ const concreteSpecRaw = concretize(original.spec);
2556
+ // Only keep a spec that actually declares something (an empty proxy spec must
2557
+ // NOT be emitted — that is the "{} is not iterable" trap).
2558
+ const originalSpec = concreteSpecRaw !== undefined && Object.keys(concreteSpecRaw).length > 0
2559
+ ? concreteSpecRaw
2560
+ : undefined;
2561
+ // Preserve the remaining declarative ObjectMeta the user may have set on the
2562
+ // owned Namespace (finding #6): metadata.finalizers + metadata.ownerReferences.
2563
+ // Server-set fields (uid/resourceVersion/managedFields/…) are intentionally not
2564
+ // carried — only declared configuration is retained. Array.isArray guards against
2565
+ // the proxy's ref-markers for undeclared fields (which are functions, not arrays).
2566
+ const finalizers = Array.isArray(originalMeta.finalizers)
2567
+ ? (concretize(originalMeta.finalizers) ?? []).map((f) => String(f))
2568
+ : undefined;
2569
+ const ownerReferences = Array.isArray(originalMeta.ownerReferences)
2570
+ ? concretize(originalMeta.ownerReferences)
2571
+ : undefined;
2572
+ return {
2573
+ name: workloadNamespace,
2574
+ labels: {
2575
+ ...resolveMap(originalMeta.labels),
2576
+ ...KroResourceFactoryImpl.INSTANCE_NAMESPACE_METADATA.labels,
2577
+ },
2578
+ annotations: {
2579
+ ...resolveMap(originalMeta.annotations),
2580
+ ...KroResourceFactoryImpl.INSTANCE_NAMESPACE_METADATA.annotations,
2581
+ },
2582
+ ...(finalizers !== undefined && finalizers.length > 0 ? { finalizers } : {}),
2583
+ ...(ownerReferences !== undefined && ownerReferences.length > 0 ? { ownerReferences } : {}),
2584
+ ...(originalSpec !== undefined && originalSpec !== null ? { spec: originalSpec } : {}),
2585
+ };
2586
+ }
2587
+ /**
2588
+ * A workload Namespace this composition owns, HOISTED out of the RGD graph and
2589
+ * emitted as a retained resource instead. It is created OUTSIDE the KRO graph —
2590
+ * deps-first in GitOps/alchemy, and applied directly in the imperative path — so
2591
+ * KRO never garbage-collects it and the instance's finalizer can never be
2592
+ * stranded by its own namespace terminating.
2593
+ *
2594
+ * The COMPLETE original Namespace configuration is preserved (finding #5) — see
2595
+ * {@link mergedHoistedNamespaceMetadata}. It is a SHARED SINGLETON deduped by the
2596
+ * namespace NAME (collision-free id, see {@link hoistedNamespaceId}), so every
2597
+ * consumer/stack targeting the SAME workload namespace emits the SAME declaration
2598
+ * — alchemy dedupes them to one retained owner rather than N fighting copies.
2599
+ */
2600
+ buildHoistedNamespaceResource(workloadNamespace, original, spec) {
2601
+ const merged = KroResourceFactoryImpl.mergedHoistedNamespaceMetadata(workloadNamespace, original, spec);
2602
+ return namespaceResource({
2603
+ metadata: {
2604
+ name: merged.name,
2605
+ labels: merged.labels,
2606
+ // OWNERSHIP RECORD (findings #4b + #2): the declarative alchemy path stamps this
2607
+ // namespace as owned by THIS composition's RGD (the annotation the empty-gated
2608
+ // teardown checks). This build step is cluster-free, so it cannot yet distinguish
2609
+ // create-vs-adopt — that is resolved at DEPLOY time by
2610
+ // `_preserveHoistedNamespaceAdoption` (resource-registration.ts), which reads the
2611
+ // live namespace and STRIPS this stamp when adopting a pre-existing namespace, so
2612
+ // teardown never deletes a namespace typekro merely adopted. Ownership is the
2613
+ // PRIMARY teardown guard; the empty-gate is the secondary one.
2614
+ annotations: { ...merged.annotations, [NAMESPACE_OWNER_ANNOTATION]: this.rgdName },
2615
+ ...(merged.finalizers !== undefined ? { finalizers: merged.finalizers } : {}),
2616
+ ...(merged.ownerReferences !== undefined
1801
2617
  ? {
1802
- annotations: {
1803
- 'typekro.io/singleton-spec-fingerprint': singletonSpecFingerprint,
1804
- },
2618
+ ownerReferences: merged.ownerReferences,
1805
2619
  }
1806
2620
  : {}),
1807
2621
  },
1808
- spec,
2622
+ ...(merged.spec !== undefined
2623
+ ? { spec: merged.spec }
2624
+ : {}),
2625
+ id: KroResourceFactoryImpl.hoistedNamespaceId(workloadNamespace),
2626
+ });
2627
+ }
2628
+ hoistedNamespaceAlchemyDeclaration(workloadNamespace, nsEnhanced, kubeConfigOptions, dependsOn) {
2629
+ const timeout = this.factoryOptions.timeout;
2630
+ const resourceId = nsEnhanced.id;
2631
+ return {
2632
+ // Collision-free (finding #7) and cluster-scoped (no namespace segment):
2633
+ // distinct workload namespaces → distinct ids; same name → same id (dedup).
2634
+ id: KroResourceFactoryImpl.hoistedNamespaceId(workloadNamespace),
2635
+ dependsOn,
2636
+ props: {
2637
+ resource: nsEnhanced,
2638
+ ...(resourceId !== undefined ? { resourceId } : {}),
2639
+ namespace: workloadNamespace,
2640
+ deploymentStrategy: 'direct',
2641
+ // EMPTY-GATED teardown (findings #3 + #4), replacing the old retain-by-name
2642
+ // distinction: on delete, the namespace is removed ONLY if empty and RETAINED
2643
+ // if another stack/user still has resources inside it. Alchemy's reverse-topo
2644
+ // teardown runs this AFTER the RGD + instance (both `dependsOn` this
2645
+ // declaration) are gone — the load-bearing delete-after-RGD ordering (a
2646
+ // namespace deleted while the CR is still inside it re-creates the finalizer
2647
+ // deadlock).
2648
+ namespaceEmptyGate: true,
2649
+ // Ownership record for the empty-gated delete (finding #4): only a namespace
2650
+ // carrying this RGD's `created-by-rgd` annotation is a candidate.
2651
+ namespaceOwnerRgd: this.rgdName,
2652
+ // CRD coordinates so the alchemy pre-hoist check enumerates EVERY existing
2653
+ // instance of this shared RGD (finding #7), not just the incoming namespace.
2654
+ namespacePreHoistQuery: {
2655
+ group: this.getSchemaGroup(),
2656
+ version: this.getSchemaVersion(),
2657
+ kind: this.schemaDefinition.kind,
2658
+ },
2659
+ kubeConfigOptions,
2660
+ options: {
2661
+ waitForReady: false,
2662
+ ...(timeout !== undefined && { timeout }),
2663
+ },
2664
+ },
1809
2665
  };
1810
2666
  }
2667
+ hoistedNamespaceYaml(workloadNamespace, original, spec) {
2668
+ const merged = KroResourceFactoryImpl.mergedHoistedNamespaceMetadata(workloadNamespace, original, spec);
2669
+ return yaml
2670
+ .dump({
2671
+ apiVersion: 'v1',
2672
+ kind: 'Namespace',
2673
+ metadata: {
2674
+ name: merged.name,
2675
+ labels: merged.labels,
2676
+ annotations: merged.annotations,
2677
+ ...(merged.finalizers !== undefined ? { finalizers: merged.finalizers } : {}),
2678
+ ...(merged.ownerReferences !== undefined
2679
+ ? { ownerReferences: merged.ownerReferences }
2680
+ : {}),
2681
+ },
2682
+ ...(merged.spec !== undefined ? { spec: merged.spec } : {}),
2683
+ }, { lineWidth: -1, noRefs: true, sortKeys: false })
2684
+ .trimEnd();
2685
+ }
1811
2686
  assertInstanceNamespaceOwnershipSafe(spec) {
1812
2687
  const compositionFn = this.factoryOptions.compositionFn;
1813
2688
  assertKroInstanceNamespaceOwnershipSafe({
1814
2689
  compositionName: this.name,
1815
- instanceNamespace: this.namespace,
2690
+ instanceNamespace: this.resolveInstanceNamespace(spec),
1816
2691
  spec,
1817
2692
  resources: this.resources,
2693
+ // The owned workload namespace is hoisted out of the graph (created retained,
2694
+ // deps-first), so the guard treats it as safe. Everything not hoisted — an
2695
+ // unprovable owned name, or an EXPLICIT `instanceNamespace` pin back onto an
2696
+ // owned namespace — still throws.
2697
+ hoistedNamespaces: this.hoistedNamespaceNames(spec),
1818
2698
  ...(compositionFn ? { compositionFn } : {}),
1819
2699
  });
1820
2700
  }
@@ -1845,11 +2725,11 @@ export class KroResourceFactoryImpl {
1845
2725
  status,
1846
2726
  metadata: {
1847
2727
  name: instanceName,
1848
- namespace: this.namespace,
2728
+ namespace: this.resolveInstanceNamespace(spec),
1849
2729
  labels: {
1850
2730
  'typekro.io/factory': this.name,
1851
2731
  'typekro.io/mode': this.mode,
1852
- 'typekro.io/rgd': this.rgdName,
2732
+ [INSTANCE_RGD_LABEL]: this.rgdName,
1853
2733
  },
1854
2734
  annotations: {
1855
2735
  'typekro.io/deployed-at': new Date().toISOString(),
@@ -1864,7 +2744,7 @@ export class KroResourceFactoryImpl {
1864
2744
  // Hydrate dynamic status fields if enabled and there are dynamic fields
1865
2745
  if (this.factoryOptions.hydrateStatus !== false && Object.keys(dynamicFields).length > 0) {
1866
2746
  try {
1867
- const hydratedDynamicFields = await this.hydrateDynamicStatusFields(instanceName, dynamicFields);
2747
+ const hydratedDynamicFields = await this.hydrateDynamicStatusFields(instanceName, dynamicFields, this.resolveInstanceNamespace(spec));
1868
2748
  // Merge evaluated static fields with dynamic fields from KRO.
1869
2749
  // Use evaluatedStaticFields (resolved markers) not raw staticFields.
1870
2750
  const mergedStatus = {
@@ -2045,7 +2925,7 @@ export class KroResourceFactoryImpl {
2045
2925
  * Wait for Kro instance to be ready with Kro-specific logic.
2046
2926
  * Delegates to the shared `waitForKroInstanceReady` in `kro-readiness.ts`.
2047
2927
  */
2048
- async waitForKroInstanceReady(instanceName, timeout) {
2928
+ async waitForKroInstanceReady(instanceName, timeout, instanceNamespace = this.resolveInstanceNamespace()) {
2049
2929
  const apiVersion = this.getInstanceApiVersion();
2050
2930
  const kubeConfig = this.getKubeConfig();
2051
2931
  const k8sApi = createBunCompatibleKubernetesObjectApi(kubeConfig);
@@ -2054,7 +2934,7 @@ export class KroResourceFactoryImpl {
2054
2934
  timeout,
2055
2935
  k8sApi,
2056
2936
  customObjectsApi: this.getCustomObjectsApi(),
2057
- namespace: this.namespace,
2937
+ namespace: instanceNamespace,
2058
2938
  apiVersion,
2059
2939
  kind: this.schemaDefinition.kind,
2060
2940
  rgdName: this.rgdName,
@@ -2064,7 +2944,7 @@ export class KroResourceFactoryImpl {
2064
2944
  /**
2065
2945
  * Hydrate dynamic status fields by evaluating CEL expressions against live Kro resource data
2066
2946
  */
2067
- async hydrateDynamicStatusFields(instanceName, dynamicFields) {
2947
+ async hydrateDynamicStatusFields(instanceName, dynamicFields, instanceNamespace = this.resolveInstanceNamespace()) {
2068
2948
  const dynamicLogger = this.logger.child({ instanceName });
2069
2949
  // Get the live custom resource to extract dynamic status fields
2070
2950
  const apiVersion = this.getInstanceApiVersion();
@@ -2075,7 +2955,7 @@ export class KroResourceFactoryImpl {
2075
2955
  kind: this.schemaDefinition.kind,
2076
2956
  metadata: {
2077
2957
  name: instanceName,
2078
- namespace: this.namespace,
2958
+ namespace: instanceNamespace,
2079
2959
  },
2080
2960
  });
2081
2961
  // In the new API, methods return objects directly (no .body wrapper)