ZoneClass is a namespace-scoped resource. It represents provider implementation, provider identity selection, provider-specific inline parameters, and the allowed Zone namespace range.
ZoneClass.spec fields:
provider.name:Provider.metadata.name. Immutable. Because it references cluster-scopedProvider, it does not include namespace or kind.provider.version:Provider.spec.versions[].name. Immutable.controllerName: provider controller instance that reconcilesZoneUnitresources reachable through this class. Required and immutable.identityRef: provider identity reference. Required.identityRef.namereferences the provider identity resource declared by the selected Provider version in the same namespace as theZoneClass.parameters: provider-specific inline settings. Validated by the referenced Provider versionzoneClass.schemas.parameters.allowedZones: namespace conditions forZoneresources that may use thisZoneClass.
ZoneClass.spec.controllerName uses the Gateway API-style domain-prefixed path form. It must include both a DNS subdomain prefix and a path part after /. Valid examples are route53.dns.appthrust.io/controller and cloudflare.dns.appthrust.io/prod-a; route53.dns.appthrust.io is invalid.
ZoneClass.spec.identityRef contains only name. It does not contain namespace, group, or kind. Cross-namespace identity references are not part of the initial design; identity resources live with the ZoneClass in the same platform namespace. The referenced Provider version declares the identity group and kind, so repeating them in every ZoneClass would create an unnecessary mismatch surface.
ZoneClass.status.conditions contains Accepted. Accepted=True means provider, identityRef, parameters, and allowedZones are statically acceptable. Accepted=False reasons are InvalidProvider, InvalidIdentityRef, InvalidParameters, and DeniedByPolicy. Accepted=Unknown reasons are IdentityNotResolved, ProviderStorageVersionNotResolved, and ReconcileError.
Example:
apiVersion: dns.appthrust.io/v1alpha1
kind: ZoneClass
metadata:
namespace: tenant-a-platform
name: route53-public
spec:
allowedZones:
namespaces:
from: Selector
selector:
matchLabels:
appthrust.io/tenant: tenant-a
provider:
name: route53.dns.appthrust.io
version: v1alpha1
controllerName: route53.dns.appthrust.io/controller
identityRef:
name: route53-dev
parameters:
zoneCreationPolicy: Create
zoneDeletionPolicy: Retain
sameNameZonePolicy: Deny
tags:
appthrust.io/project: dns-api
appthrust.io/environment: developmentZoneClass is not reconciled by a core controller. The core validating webhook reads the referenced Provider version and validates spec.identityRef, spec.parameters, and spec.allowedZones.
Provider controllers reconcile only ZoneClass resources whose spec.controllerName matches their own controller name. If it does not match, they do not update status. They interpret identityRef according to the Provider version identity resource declaration, interpret provider-specific parameters, and set ZoneClass.status.conditions[Accepted] to True when the class is statically acceptable.
Zone is a namespace-scoped resource. It is the management unit corresponding to a hosted zone / zone on the DNS provider, connecting Kubernetes ownership boundaries with the provider-side object.
Zone.spec fields:
domainName: zone apex. Only canonical ASCII values are accepted. Immutable.zoneClassRef: selectedZoneClass. If namespace is omitted, it defaults to theZonenamespace. Immutable.provider.name:Provider.metadata.name. Required and immutable.provider.version:Provider.spec.versions[].name. Required and immutable.adoption: specified when adopting an existing external zone. Omitted for creation. Mutable, but provider controllers stop changes that point to a different external zone after management starts.adoption: provider-specific opaque object validated by the referenced Provider versionzone.schemas.adoption.allowedRecordSets: conditions under whichRecordSetresources from other namespaces may attach. If omitted, only same-namespaceRecordSetresources are allowed. Each rule combines a namespace selector and record policy.
Zone.spec.provider duplicates ZoneClass.spec.provider so GitOps can apply Zone and ZoneClass in any order while still validating Zone.spec.adoption and Zone provider-specific validation rules. Admission validates the declared Provider version and provider-specific payloads, but it does not reject a Zone because the referenced ZoneClass is absent or because Zone.spec.provider differs from ZoneClass.spec.provider. The Core ZoneUnit Controller reports those reference and Provider consistency problems later as Accepted=Unknown or Accepted=False.
managementPolicy, useExisting, and protected are not part of the initial Zone.spec.
Zone deletion policy is provider-specific ZoneClass policy. For Route 53 it is ZoneClass.spec.parameters.zoneDeletionPolicy. The core Zone.spec has no deletion policy.
Zone is a claim reconciled by the Core ZoneUnit Controller. The core validating webhook validates domainName, zoneClassRef, provider, adoption, and the object-local shape of allowedRecordSets.
The Core ZoneUnit Controller resolves ZoneClass from zoneClassRef, checks Zone.spec.provider against ZoneClass.spec.provider, applies ZoneClass.spec.allowedZones, converts provider payload to Provider storage version, and writes the accepted desired state to ZoneUnit.spec.zone. Provider controllers do not reconcile Zone directly and do not write Zone.status; they read ZoneUnit.spec and write ZoneUnit.status. The Core ZoneUnit Controller projects ZoneUnit.status.zone back to Zone.status.
Example:
apiVersion: dns.appthrust.io/v1alpha1
kind: Zone
metadata:
namespace: tenant-a-app
name: apps-example-com
spec:
domainName: apps.example.com
provider:
name: route53.dns.appthrust.io
version: v1alpha1
zoneClassRef:
namespace: tenant-a-platform
name: route53-public
allowedRecordSets:
- namespaces:
selector:
matchLabels:
appthrust.io/dns-access: apps-example-com
records:
- name:
pattern: '^app-a$'
types:
- A
- AAAA
- CNAMEallowedRecordSets interpretation:
- Rules are evaluated as OR.
- Within one rule,
namespacesandrecordsare evaluated as AND. namespaces.selectoris evaluated against labels on the namespace containing theRecordSet.- Entries in
recordsare evaluated as OR. records[].name.patternis evaluated against the normalizedRecordSet.spec.name.records[].typesis an allow list forRecordSet.spec.type.- Direct namespace name lists are not provided. Even a single namespace is selected through namespace labels.
Schema shape:
allowedRecordSets:
- namespaces:
selector:
matchLabels:
appthrust.io/dns-access: apps-example-com
records:
- name:
pattern: '[a-z0-9]([-a-z0-9]*[a-z0-9])?'
types:
- ACRD schema requires allowedRecordSets[*].namespaces, allowedRecordSets[*].namespaces.selector, allowedRecordSets[*].records, allowedRecordSets[*].records[*].name, allowedRecordSets[*].records[*].name.pattern, and allowedRecordSets[*].records[*].types. records and types must not be empty arrays.
namespaces.selector uses the Kubernetes LabelSelector shape, but an empty selector is not allowed. The initial design does not provide a policy that opens a shared zone to all namespaces.
The webhook validates that each record name pattern compiles as a regular expression, that each type is supported by core or provider schema, and that the namespace selector is not empty.
records[].name.pattern is a regular expression. There is no custom DSL. The webhook validates that patterns compile on Zone create/update. Patterns always use full-match semantics, so users do not need to write ^ and $. For example, foo matches only foo, not foo.bar. To allow @, *, or *.foo, include them explicitly in the pattern.
The pattern target is only normalized RecordSet.spec.name, not an expanded FQDN. Examples: www, apex @, wildcard *, and subdomain wildcard *.foo.
records[].name.pattern: '.*' is allowed. It is explicit policy granting all record names for the selected namespaces and record types.
records[].types is compared with RecordSet.spec.type. Provider options do not change this comparison. Route 53 ALIAS is represented as type=A or type=AAAA plus spec.options.alias, so it is allowed by types: [A] or types: [AAAA].
Examples:
records:
- name:
pattern: '[a-z0-9]([-a-z0-9]*[a-z0-9])?'
types:
- AAllows only single-label non-apex, non-wildcard A records.
records:
- name:
pattern: '[a-z0-9]([-a-z0-9]*[a-z0-9])?\.team-a'
types:
- AAllows A records one level under team-a, such as foo.team-a.
records:
- name:
pattern: '_acme-challenge\.[a-z0-9]([-a-z0-9]*[a-z0-9])?'
types:
- TXTAllows TXT records for ACME challenges.
For cross-namespace access, records is required. When a shared zone is exposed to application namespaces, namespace-only grants could allow editing apex, wildcard, NS, TXT, or other important records. Therefore record name and type restrictions are written together. @, *, *.foo, NS, and TXT are not allowed unless explicitly granted by record policy.
allowedRecordSets is cross-namespace policy only. If a RecordSet references a Zone in the same namespace, allowedRecordSets is not evaluated. Same namespace is treated as one trust boundary. This allows application engineers to own a custom-domain Zone and manage RecordSet resources in the same namespace. Shared zones are placed in a platform namespace and limited with allowedRecordSets.
Admission validates only the object-local shape of allowedRecordSets: required fields, selector syntax, record name pattern syntax, and record type values. It does not reject a RecordSet create/update because the current Zone, RecordSet namespace labels, record name, or record type are outside the policy. The Core ZoneUnit Controller evaluates the saved objects and sets RecordSet.status.conditions[Accepted] to False, reason NotAllowedByZone, when a cross-namespace RecordSet is not allowed by its referenced Zone.
When updating Zone.spec.allowedRecordSets, admission does not list existing RecordSet resources and does not reject policy shrink. If the new policy makes existing cross-namespace RecordSet resources disallowed, the Core ZoneUnit Controller re-evaluates them and returns Accepted=False, reason NotAllowedByZone. Same-namespace RecordSet resources are not affected by allowedRecordSets.
Policy drift alone does not delete external DNS records. Even if webhook downtime or races make an existing RecordSet disallowed, controllers return Accepted=False, reason NotAllowedByZone, and do not proceed with create/update reconciliation. If the RecordSet already has an owner item in ZoneUnit.spec.recordSets[], core keeps that item as an ownership and cleanup ledger with recordSets[].allowed: false. Provider controllers must not create or update a provider-side record for an item with allowed: false while deletionRequested is false. External records are deleted only when the owner RecordSet is deleted and the matching ZoneUnit.spec.recordSets[] item requests deletion.
Namespace label updates are not rejected by dns-api admission. allowedRecordSets depends on namespace selectors, so label changes can move existing RecordSet resources out of policy. Controllers treat this as policy drift and return Accepted=False, reason NotAllowedByZone. dns-api does not intercept cluster-scoped Namespace updates.
All allowedRecordSets rejections use reason NotAllowedByZone. Namespace selector mismatch, record name pattern mismatch, and record type mismatch are not separate reasons. RecordSet.status.conditions[Accepted].message uses this wording contract:
- If
RecordSetandZoneare in the same namespace, allow the request and do not evaluateallowedRecordSets. - If a cross-namespace
RecordSethas no rule matching its namespace labels, useRecordSet namespace is not allowed by the referenced Zone. - If a namespace rule matches but no
records[].name.patternmatchesRecordSet.spec.name, useRecordSet name is not allowed by the referenced Zone. - If namespace and record name match but
RecordSet.spec.typeis not included inrecords[].types, useRecordSet type is not allowed for this RecordSet name by the referenced Zone.
These messages are coarse troubleshooting categories. They must not include selectors, namespace labels, record name patterns, allowed type lists, or rule indexes.
Initial kest success paths do not cover cross-namespace allowedRecordSets. Initial kest uses same-namespace Zone and RecordSet. Shared-zone policy is covered by envtest or later kest scenarios.
Zone.spec.domainName is not mutated by the admission webhook. To keep stored values directly comparable, only canonical values are accepted.
Accepted:
example.com: lowercase, no trailing root dot.xn--bcher-kva.example: IDN as Punycode ASCII.
Rejected:
Example.COM.: uppercase or trailing root dot.bücher.example: Unicode IDN.
Zone.status contains standard conditions and minimal core API values. Provider-specific structured status is stored as an inline object at status.provider.data.
When the Route 53 controller has created or confirmed a hosted zone, Zone.status.nameServers contains the Route 53 assigned name servers. Route 53 hosted zone ID is stored in Zone.status.provider.data. Caller reference and pending change information are internal provider state and are stored in ZoneUnit.status.provider.state when needed.
Example:
status:
nameServers:
- ns-123.awsdns-45.com
- ns-678.awsdns-90.net
provider:
data:
hostedZoneID: Z1234567890Accepted means the resource is acceptable before calling the provider API: references, namespace policy, and platform policy are valid. Detailed ZoneClass problems live on ZoneClass.status.
Accepted=False reasons:
InvalidZoneClassRef:zoneClassRefcannot be resolved or has an invalid reference shape.NotAllowedByZoneClass: theZonedoes not matchZoneClass.spec.allowedZones.ZoneClassNotAccepted: the referencedZoneClassis not usable.InvalidIdentityRef: provider-specific identity reference has invalid shape, or the resolved identity is rejected or not usable.InvalidProvider:Zone.spec.providercannot be resolved, the Provider version has noZoneschema, or the Provider does not match the responsible controller.ProviderMismatch:Zone.spec.providerdiffers from referencedZoneClass.spec.provider.InvalidProviderPayload: provider-owned inline payload does not match the selected Provider schema.ProviderConversionFailed: provider-owned inline payload conversion failed permanently or returned invalid storage payload.InvalidAdoption:spec.adoptiondoes not match the provider schema.ManagedResourceMismatch:spec.adoptionpoints to a different external zone than the one already managed.DeniedByPolicy: selectedZoneClassor provider parameters reject the resource through preflight policy.
Accepted=Unknown reasons include unresolved material and temporary conversion failures:
IdentityNotResolved: provider-specific identity object or its accepted state is not resolved enough to decide.ProviderConversionUnavailable: Provider conversion webhook is temporarily unavailable, timed out, or returned a retryable failure.
Programmed means the desired state has been reflected to the provider.
Programmed=True does not mean DNS is resolvable. It means the last provider-side state observed by the controller matches the accepted desired state and there is no pending provider change. For Route 53, Programmed=True means no pendingHostedZoneChange exists in ZoneUnit.status.provider.state and the observed hosted zone matches ZoneUnit.spec.zone and ZoneClass.spec.parameters. Parent-zone NS delegation, public DNS resolvability, and resolver reachability are not guaranteed.
Programmed=False reasons:
ProviderChangePending: the provider accepted a change and completion is pending.ProviderIdentityNotReady: the provider-specific identity has not passed live credential checks.ExternalResourceNotFound: the resource referenced or derived byspec.adoptiondoes not exist.ExternalResourceMismatch: the resource referenced or derived byspec.adoptionexists but does not match spec.ProviderConflict: provider-side conflict such as an existing same-name zone.ProviderAccessDenied: provider API permission failure.ProviderInvalidRequest: provider API rejected the request as invalid.ProviderUnavailable: provider API outage, rate limit, or temporary failure.ReconcileError: other controller internal error.
Zone status validation accepts only Accepted and Programmed condition types. condition.status must be True, False, or Unknown. condition.reason must match the core reason set for the condition type and status, or an entry declared in the selected Provider version zone.additionalConditionReasons.
Core Zone condition reason combinations:
Accepted=True:Accepted.Accepted=False:InvalidZoneClassRef,NotAllowedByZoneClass,ZoneClassNotAccepted,InvalidIdentityRef,InvalidProvider,ProviderMismatch,InvalidProviderPayload,ProviderConversionFailed,InvalidAdoption,ManagedResourceMismatch,DeniedByPolicy.Accepted=Unknown:Reconciling,ZoneClassNotResolved,IdentityNotResolved,ProviderStorageVersionNotResolved,ProviderConversionUnavailable,OwnerStateNotResolved,ReconcileError.Programmed=True:Programmed.Programmed=False:ProviderChangePending,ProviderIdentityNotReady,ExternalResourceNotFound,ExternalResourceMismatch,ProviderConflict,ProviderAccessDenied,ProviderInvalidRequest,ProviderUnavailable,ReconcileError.Programmed=Unknown:Reconciling,ProviderChangePending,ProviderUnavailable,ReconcileError.
Zone.status.nameServers is validated independently of provider data. An empty or omitted list is allowed before provider-assigned name servers are observed. Every non-empty item must be a canonical lowercase ASCII DNS name without a trailing root dot. Unicode, uppercase, empty labels, trailing dots, IP addresses, URLs, and URIs are rejected. Duplicate name servers are rejected after DNS-name normalization.
Kubernetes Events are not the source of truth for Zone state. Zone.status.conditions and Zone.status.provider.data are the source of truth. Events are diagnostic information for users and UI. Event retention, aggregation, and ordering depend on cluster configuration, so automation and GitOps must not depend on Events.
The Core ZoneUnit Controller normally emits an Event when a Zone.status.conditions status or reason changes. It does not emit repeated Events when re-observation yields the same state. During API error retries, it does not repeat Events while the same condition reason continues.
Zone Event contract fields:
type:NormalorWarning.reason: for condition-derived Events, the same as the condition reason.message: short human-readable diagnostic text. It must not contain secrets, credentials, or raw provider SDK error bodies.related: set only when there is a next Kubernetes resource to inspect. It is omitted when references cannot be resolved or when the external resource has no Kubernetes object.
Core Event reasons do not have provider prefixes. Provider-specific Event reasons start with the provider name to avoid collisions because Kubernetes Event reasons are not namespaced. Route 53-specific reasons start with Route53, such as Route53HostedZoneChangeSubmitted. reportingController identifies the emitter but is not used as the reason namespace.
Machine-processable provider data belongs in Zone.status.provider.data, not Events. Provider identity authentication details belong on provider identity status, not Zone Events.
Zone Event messages contain only minimal information needed for next steps: usually domainName, condition reason, and relevant resource references. For example, Accepted messages include the referenced ZoneClass; ProviderIdentityNotReady and ProviderAccessDenied include provider identity references; deletion and retention messages state that the external zone was deleted or retained.
Example messages:
Zone apps.example.com was accepted by ZoneClass tenant-a-platform/route53-public.
Zone apps.example.com is waiting for Route53Identity tenant-a-platform/route53-dev.
Provider accepted a hosted zone change for apps.example.com and is waiting for completion.
Zone apps.example.com was programmed with hosted zone Z1234567890.
Zone apps.example.com was deleted and the external hosted zone was retained.
Common Zone Events:
| type | reason | related | When emitted |
|---|---|---|---|
Normal |
Accepted |
ZoneClass |
Accepted=True. |
Warning |
InvalidZoneClassRef |
resolved ZoneClass if any |
Accepted=False due to invalid zoneClassRef. |
Warning |
NotAllowedByZoneClass |
ZoneClass |
Accepted=False due to ZoneClass.spec.allowedZones. |
Warning |
ZoneClassNotAccepted |
ZoneClass |
Accepted=False because the referenced class is not usable. |
Warning |
InvalidIdentityRef |
resolved provider identity if any | provider identity reference is invalid. |
Warning |
InvalidProvider |
resolved Provider if any |
provider schema or version is invalid. |
Warning |
ProviderMismatch |
ZoneClass |
Zone.spec.provider differs from ZoneClass.spec.provider. |
Warning |
InvalidAdoption |
none | adoption payload does not match provider schema. |
Warning |
DeniedByPolicy |
ZoneClass |
provider or platform policy rejects the Zone. |
Warning |
ManagedResourceMismatch |
none | adoption points to a different external zone than the managed one. |
Normal |
ProviderChangePending |
none | provider accepted a hosted zone change and completion is pending. |
Normal |
Programmed |
none | Programmed=True. |
Warning |
ExternalResourceNotFound |
none | adoption target does not exist. |
Warning |
ExternalResourceMismatch |
none | adoption target exists but does not match spec. |
Warning |
ProviderConflict |
none | provider-side same-name or deletion conflict. |
Warning |
ProviderAccessDenied |
provider identity | provider API permission failure. |
Warning |
ProviderInvalidRequest |
Zone |
provider API rejected the request. |
Warning |
ProviderUnavailable |
none | provider API outage, rate limit, or temporary failure. |
Warning |
ReconcileError |
none | controller internal error. |
Lifecycle helper Events:
| type | reason | related | When emitted |
|---|---|---|---|
Normal |
ExternalResourceAdopted |
none | external zone referenced by adoption is brought under management. |
Normal |
ExternalResourceDeleted |
none | external zone is deleted during Zone deletion. |
Normal |
ExternalResourceRetained |
none | external zone is retained during Zone deletion. |
RecordSet is a namespace-scoped resource that declares a DNS record set in a Zone.
RecordSet.spec fields:
zoneRef: reference to the associatedZone. If namespace is omitted, it defaults to theRecordSetnamespace. Immutable.provider.name:Provider.metadata.name. Required and immutable.provider.version:Provider.spec.versions[].name. Required and immutable.type: DNS record type. Required and immutable. The initial API defines standard bodies forA,AAAA,TXT,CNAME,MX,CAA, and delegatedNS. StandardSRVbodies are not defined initially.name: owner name within the zone. Required and immutable.ttl: TTL seconds. Optional integer in CRD schema. Value range1..2147483647. Mutable.a: standard A record body.aaaa: standard AAAA record body.txt: standard TXT record body.cname: standard CNAME record body.mx: standard MX record body.caa: standard CAA record body.ns: standard delegated NS record body.options: provider-specific opaque object validated by the referenced Provider versionrecordSet.schemas.options.adoption: specified when adopting an existing external record set. Omitted for creation. Mutable, with the same managed-resource guard asZone.adoption: provider-specific opaque object validated by the referenced Provider versionrecordSet.schemas.adoption.
RecordSet.spec.provider duplicates ZoneClass.spec.provider so GitOps can apply RecordSet, Zone, and ZoneClass in any order while still validating RecordSet.spec.options and RecordSet.spec.adoption. Admission validates the declared Provider version and provider-specific payloads, but it does not reject a RecordSet because the referenced Zone or ZoneClass is absent or because RecordSet.spec.provider differs from ZoneClass.spec.provider. The Core ZoneUnit Controller reports those reference and Provider consistency problems later as Accepted=Unknown or Accepted=False.
Provider version recordSet.supportedTypes is the list of spec.type values that the provider schema can accept. In v1alpha1, every listed type must have a core standard body and core validation. The only supported values are A, AAAA, TXT, CNAME, MX, CAA, and delegated NS. Provider admission rejects supportedTypes entries such as SRV, SVCB, HTTPS, DS, DNSKEY, PTR, SSHFP, URI, or any other type without a core standard body. Providers may add constraints for the supported standard types, but they cannot introduce body-less record types through provider-specific options.
DNS record identity is zoneRef, type, and name. The Core ZoneUnit Controller evaluates this identity when it builds ZoneUnit.spec.recordSets[]. Record name is not repeated inside record bodies. Same zoneRef, type, and name always conflicts. CNAME additionally has same-owner-name exclusivity by default: type=CNAME for a zoneRef and name conflicts with every other type at that same zoneRef and name, and every non-CNAME type conflicts with an existing CNAME at the same zoneRef and name. This default composition validation is named forbid-cname-coexistence and can be disabled for specific provider-supported type pairs by Provider version zoneUnit.disableValidations.
Record name grammar:
recordName : apexName | wildcardName | relativeName ;
apexName : "@" ;
wildcardName : "*" | "*." relativeName ;
relativeName : label ("." label)* ;
label : alnum | alnum labelChar* alnum ;
labelChar : alnum | "-" ;
alnum : "a".."z" | "0".."9" ;
Record names are relative to Zone.spec.domainName and stored without a trailing root dot. The admission webhook rejects uppercase, Unicode, empty labels, and trailing dots even when the referenced Zone does not exist yet. It rejects owner names that produce an FQDN longer than DNS limits when the referenced Zone can be resolved and combined with the zone domain.
Record name validation is type-dependent and split into intrinsic and reference-dependent checks. Intrinsic checks use only RecordSet.spec.type, RecordSet.spec.name, and the selected Provider version; they run even when RecordSet.spec.zoneRef points to a missing Zone for GitOps ordering. Intrinsic checks include canonical lowercase ASCII, label grammar, label length, wildcard placement, type-specific underscore allowance, apex CNAME policy, delegated NS apex rejection, and delegated NS wildcard rejection. Reference-dependent checks, such as full FQDN length with the zone domain and namespace policy, run only when the referenced Zone and ZoneClass can be resolved.
The grammar above is the normal host-style label grammar. TXT and CNAME records additionally allow underscore-prefixed labels such as _acme-challenge and _0123456789abcdef for DNS verification workflows. Underscore labels are stored as sent, must be lowercase ASCII, and must not be empty after _.
Canonical examples for Zone.spec.domainName=example.com:
@:example.com.*:*.example.com.*.foo:*.foo.example.com.www:www.example.com.api.v1:api.v1.example.com.
Wildcard labels are always * in dns-api canonical record names. Provider-specific escaped representations such as Route 53 \052 are never exposed in RecordSet.spec.name, ZoneUnit.spec.recordSets[].name, adoption payloads, or status.
spec.txt.values items must be non-empty UTF-8 strings with length 1..4000 octets. Exact duplicate strings in the same array are not allowed. The webhook does not canonicalize case, spaces, quote characters, or escape strings. User strings are stored as-is in spec. Provider controllers convert provider-side TXT representations back to logical TXT values and compare as unordered sets. Route 53 quote, escape, and 255-octet chunk boundaries alone must not be treated as drift.
spec.a.addresses accepts IPv4 addresses only. spec.aaaa.addresses accepts IPv6 addresses only. type=A forbids spec.aaaa, spec.txt, spec.cname, spec.mx, spec.caa, and spec.ns. type=AAAA forbids spec.a, spec.txt, spec.cname, spec.mx, spec.caa, and spec.ns. type=TXT forbids spec.a, spec.aaaa, spec.cname, spec.mx, spec.caa, and spec.ns. type=CNAME forbids spec.a, spec.aaaa, spec.txt, spec.mx, spec.caa, and spec.ns. type=MX forbids spec.a, spec.aaaa, spec.txt, spec.cname, spec.caa, and spec.ns. type=CAA forbids spec.a, spec.aaaa, spec.txt, spec.cname, spec.mx, and spec.ns. type=NS forbids spec.a, spec.aaaa, spec.txt, spec.cname, spec.mx, and spec.caa. Provider-specific options that make standard body fields unnecessary use Provider version recordSet.disableValidations to disable required core validations and provider CEL rules to reject conflicting fields.
Address arrays do not allow duplicate IP addresses. Duplicate checks compare parsed IP values, not raw strings. IPv6 forms such as 2001:db8::10 and 2001:0db8:0000::0010 are semantic duplicates.
dns-api does not mutate address strings. RecordSet.spec.a.addresses and RecordSet.spec.aaaa.addresses store strings exactly as sent by users. The webhook rejects unparsable or semantically duplicate addresses but does not rewrite IPv6 forms. Provider controllers parse desired and observed values and compare canonical IP value sets, not raw strings. Textual representation differences alone are not drift.
spec.cname.target is the canonical name returned for a CNAME record. It is a DNS name, not an IP address, URL, URI, or Route 53 ALIAS target. It is stored as lowercase ASCII without a trailing root dot. Punycode labels are accepted. Unicode, uppercase, empty labels, trailing dots, and URL-like strings are rejected. The webhook does not mutate spec.cname.target; non-canonical input is rejected. CNAME target labels may use underscore-prefixed labels for verification workflows such as certificate validation. Provider controllers compare observed CNAME target names after normalizing trailing dot differences and provider-specific escaping.
Core standard CNAME forbids spec.name: "@" by default because a normal CNAME record cannot be created at the zone apex. This default is named core validation forbid-cname-apex. A Provider version may disable forbid-cname-apex only when the provider has explicit apex CNAME behavior, such as Cloudflare apex CNAME flattening. Disabling forbid-cname-apex does not disable CNAME target validation, required spec.cname.target, record name grammar, or ZoneUnit same-owner-name composition validation. To point a zone apex at an AWS resource with Route 53, use Route 53 ALIAS on type=A or type=AAAA, not standard CNAME.
spec.mx.records is a non-empty array of mail exchange entries. Each item has preference and exchange. preference is an integer in range 0..65535; lower values have higher delivery priority. exchange is the mail server DNS name. It is stored as lowercase ASCII without a trailing root dot. Punycode labels are accepted. Unicode, uppercase, empty labels, trailing dots, IP addresses, URLs, and URIs are rejected. The webhook does not mutate spec.mx.records; non-canonical input is rejected. Exact duplicate (preference, exchange) pairs are not allowed. The same preference with different exchanges is allowed. Provider controllers compare observed MX records as unordered sets of (preference, exchange) pairs after normalizing trailing dot differences and provider-specific escaping.
type=MX may use spec.name: "@". Publishing MX records at the zone apex is a normal use case. Unlike CNAME, MX does not own the whole owner name by itself and may coexist with non-CNAME record types at the same Zone and record name. It only conflicts when a CNAME exists at the same owner name, through the CNAME same-name exclusivity rule.
The core webhook does not check whether spec.mx.records[].exchange resolves, has A or AAAA records, is inside the same Zone, is managed by dns-api, or is reachable by mail delivery agents. Those are DNS resolution, propagation, or operational checks and are outside the initial core API. exchange may point to a DNS name outside the managed zone.
Null MX is supported by using exactly one MX entry with preference: 0 and exchange: ".". This declares that the owner name accepts no email. exchange: "." is allowed only for Null MX, must use preference: 0, and must not be combined with any other MX entry. Other MX exchanges must not be ".".
spec.caa.records is a non-empty array of Certification Authority Authorization entries. Each item has flags, tag, and value. flags is an integer in range 0..255. tag is a non-empty lowercase ASCII alphanumeric string. The initial validation uses the CAA tag grammar instead of a closed enum so future CAA property tags can be used without changing the core API. Known tags such as issue, issuewild, and iodef are normal values, not the complete allowed set. value is a non-empty string. Exact duplicate (flags, tag, value) entries are not allowed. The same tag with different values is allowed. Provider controllers compare observed CAA records as unordered sets of (flags, tag, value) tuples after normalizing provider-specific quoting and escaping.
type=CAA may use spec.name: "@". Publishing CAA records at the zone apex is a normal use case. CAA does not own the whole owner name by itself and may coexist with non-CNAME record types at the same Zone and record name. It only conflicts when a CNAME exists at the same owner name, through the CNAME same-name exclusivity rule.
The core webhook does not validate CA-specific semantics inside spec.caa.records[].value. For example, it does not check whether an issue value names a real CA domain, whether account parameters are valid, whether an iodef value is a deliverable email address or reachable URL, or whether the configured CA actually honors the policy. Those are CA ecosystem or operational checks and are outside the initial core API.
spec.ns.nameServers is a non-empty array of delegated name server DNS names. NS RecordSet is only for child zone delegation. It is not the authoritative name server set assigned to the current Zone itself. Assigned hosted zone name servers remain Zone.status.nameServers. Each delegated name server is stored as lowercase ASCII without a trailing root dot. Punycode labels are accepted. Unicode, uppercase, empty labels, trailing dots, IP addresses, URLs, and URIs are rejected. Exact duplicate name servers are not allowed. Provider controllers compare observed delegated NS records as unordered sets after normalizing trailing dot differences and provider-specific escaping.
type=NS must not use spec.name: "@". Apex NS records are provider-managed zone records in the initial API and are not managed through RecordSet. Use Zone.status.nameServers to observe the provider-assigned apex name servers for the current Zone. type=NS also must not use wildcard owner names such as * or *.foo; delegated NS is for explicit child owner names such as team-a or dev.platform.
The core webhook does not check whether spec.ns.nameServers[] resolves, is authoritative for the delegated child zone, has glue records, is inside the same Zone, is managed by dns-api, or is reachable by resolvers. Those are DNS delegation, propagation, or operational checks and are outside the initial core API. Delegated name servers may point to DNS names outside the managed zone.
Core API does not define provider-specific spec.options fields for CNAME, MX, CAA, or delegated NS. spec.options remains provider-defined and is validated only by the referenced Provider version schema and CEL rules. Core validation must not name or interpret Route 53-specific fields such as spec.options.alias.
The initial API does not provide standard body fields for types other than A, AAAA, TXT, CNAME, MX, CAA, and delegated NS. Headlamp plugin, manual, sample manifests, and Provider manifests must not present or declare standard SRV as a creation target until field names, validation, examples, and provider schema support levels are added to this design.
RecordSet is a claim reconciled by the Core ZoneUnit Controller. The core validating webhook validates object-local fields and provider payloads. It does not require the referenced Zone or ZoneClass to exist before saving the object.
The Core ZoneUnit Controller resolves the referenced Zone, ZoneClass, and Provider, applies Zone.spec.allowedRecordSets, evaluates DNS ownership and compatibility rules, converts provider payload to Provider storage version, and writes accepted items to ZoneUnit.spec.recordSets[]. If a previously accepted RecordSet becomes disallowed by Zone.spec.allowedRecordSets, core keeps the existing ZoneUnit.spec.recordSets[] item with allowed: false so ownership and cleanup state are not lost. Rejected or unresolved claims are reported directly on RecordSet.status.conditions[Accepted]. Provider controllers do not read RecordSet claims; they read ZoneUnit.spec.recordSets[] and write ZoneUnit.status.recordSets[]. The Core ZoneUnit Controller projects those provider results back to RecordSet.status.
Standard A example:
apiVersion: dns.appthrust.io/v1alpha1
kind: RecordSet
metadata:
namespace: tenant-a-app
name: www
spec:
zoneRef:
name: example-com
provider:
name: route53.dns.appthrust.io
version: v1alpha1
type: A
name: www
ttl: 300
a:
addresses:
- 192.0.2.10Cloudflare proxied, comment, tags, and automatic TTL are provider options. Automatic TTL is not represented by the core TTL type; it lives in Cloudflare provider schema spec.options.ttl. proxied is supported only for A, AAAA, and CNAME. Cloudflare fixed TTL accepts only 60..86400 in v1alpha1 through Provider recordSet.validationRules; spec.ttl: 1 is not the automatic TTL form for Cloudflare.
Cloudflare A example:
apiVersion: dns.appthrust.io/v1alpha1
kind: RecordSet
metadata:
namespace: tenant-a-app
name: www
spec:
zoneRef:
name: example-com
provider:
name: cloudflare.dns.appthrust.io
version: v1alpha1
type: A
name: www
options:
ttl: Auto
proxied: true
comment: app endpoint
tags:
- app:frontend
a:
addresses:
- 192.0.2.10Cloudflare provider schema disables require-ttl only when spec.options.ttl=Auto exists and uses CEL validation to reject simultaneous spec.ttl and spec.options.ttl. When spec.options.proxied=true, Cloudflare provider validation requires automatic TTL. The controller maps spec.options.ttl=Auto to Cloudflare API ttl=1; users must not express automatic TTL as core spec.ttl: 1.
Standard AAAA example:
apiVersion: dns.appthrust.io/v1alpha1
kind: RecordSet
metadata:
namespace: tenant-a-app
name: www-v6
spec:
zoneRef:
name: example-com
provider:
name: route53.dns.appthrust.io
version: v1alpha1
type: AAAA
name: www
ttl: 300
aaaa:
addresses:
- 2001:db8::10Standard TXT example:
apiVersion: dns.appthrust.io/v1alpha1
kind: RecordSet
metadata:
namespace: tenant-a-app
name: acme-challenge
spec:
zoneRef:
name: example-com
provider:
name: route53.dns.appthrust.io
version: v1alpha1
type: TXT
name: _acme-challenge
ttl: 300
txt:
values:
- challenge-token
- v=spf1 include:_spf.example.net ~allStandard CNAME example:
apiVersion: dns.appthrust.io/v1alpha1
kind: RecordSet
metadata:
namespace: tenant-a-app
name: www-cname
spec:
zoneRef:
name: example-com
provider:
name: route53.dns.appthrust.io
version: v1alpha1
type: CNAME
name: www
ttl: 300
cname:
target: app.example.netStandard MX example:
apiVersion: dns.appthrust.io/v1alpha1
kind: RecordSet
metadata:
namespace: tenant-a-app
name: mail
spec:
zoneRef:
name: example-com
provider:
name: route53.dns.appthrust.io
version: v1alpha1
type: MX
name: "@"
ttl: 300
mx:
records:
- preference: 10
exchange: mail1.example.net
- preference: 20
exchange: mail2.example.netNull MX example:
apiVersion: dns.appthrust.io/v1alpha1
kind: RecordSet
metadata:
namespace: tenant-a-app
name: no-mail
spec:
zoneRef:
name: example-com
provider:
name: route53.dns.appthrust.io
version: v1alpha1
type: MX
name: "@"
ttl: 300
mx:
records:
- preference: 0
exchange: "."Standard CAA example:
apiVersion: dns.appthrust.io/v1alpha1
kind: RecordSet
metadata:
namespace: tenant-a-app
name: ca-policy
spec:
zoneRef:
name: example-com
provider:
name: route53.dns.appthrust.io
version: v1alpha1
type: CAA
name: "@"
ttl: 300
caa:
records:
- flags: 0
tag: issue
value: letsencrypt.org
- flags: 0
tag: issuewild
value: ";"
- flags: 0
tag: iodef
value: mailto:security@example.comDelegated NS example:
apiVersion: dns.appthrust.io/v1alpha1
kind: RecordSet
metadata:
namespace: tenant-a-app
name: team-a-delegation
spec:
zoneRef:
name: example-com
provider:
name: route53.dns.appthrust.io
version: v1alpha1
type: NS
name: team-a
ttl: 300
ns:
nameServers:
- ns-111.example-dns.net
- ns-222.example-dns.netZoneUnit is the zone-scoped desired-state and ownership ledger built from accepted Zone and RecordSet claims. It is namespace-scoped and uses the same namespace and name as the owning Zone. Users normally create Zone and RecordSet claims; the Core ZoneUnit Controller creates and updates ZoneUnit.
ZoneUnit.metadata.name must match spec.zone.ref.name; the CRD validates this with CEL. ZoneUnit.metadata.namespace must match spec.zone.ref.namespace; Kubernetes CRD CEL does not expose metadata.namespace, so the core validating webhook rejects namespace mismatches on create and update. Provider controllers do not define an InvalidZoneUnitName condition reason and do not handle mismatched names defensively, because invalid ZoneUnit names are rejected before provider reconciliation.
ZoneUnit.spec is the provider-controller input. The Core ZoneUnit Controller writes it after resolving references, applying core policy, evaluating provider contract rules, converting provider payloads to the Provider storage version, and resolving record ownership conflicts. Provider controllers read ZoneUnit.spec and do not read Zone or RecordSet claims.
ZoneUnit.spec fields:
provider.name: Provider resource name, such asroute53.dns.appthrust.io. Required and immutable.provider.version: Provider storage version used inside theZoneUnit. Required and immutable.zone: accepted desired state for the owningZone.zone.ref: namespace/name reference to the owningZone.zone.observedGeneration:Zone.metadata.generationused to build this item.zone.domainName: accepted zone domain name.zone.zoneClassRef: resolvedZoneClassreference.zone.adoption: provider-specific adoption payload after storage-version conversion.recordSets: accepted desired state for ownerRecordSetclaims.recordSets[].recordSetNamespace: ownerRecordSetnamespace. This is part of the list-map key.recordSets[].recordSetName: ownerRecordSetname. This is part of the list-map key.recordSets[].observedGeneration:RecordSet.metadata.generationused to build this item.recordSets[].name:RecordSet.spec.nameas a zone-relative DNS name, not an FQDN.recordSets[].type:RecordSet.spec.type.recordSets[].ttl, body fields,options, andadoption: provider-storage desired state used for active reconciliation and retained cleanup.recordSets[].allowed: omitted ortruewhen the item is allowed by current composition policy.falsemeans the item is retained only as an ownership and cleanup ledger afterZone.spec.allowedRecordSetschanged. Provider controllers must not create or update provider-side records forallowed: falseitems unlessdeletionRequestedis true.recordSets[].deletionRequested: set by core when the ownerRecordSetis deleting and provider-side cleanup is still needed.
recordSets is a Kubernetes listType=map keyed by scalar fields recordSetNamespace and recordSetName. recordSetRef is not used inside the list item because nested-object list-map keys are not a CRD-safe shape across Kubernetes versions. UID is not a key because provider controllers do not read RecordSet claims and therefore cannot rely on RecordSet.metadata.uid. When a RecordSet has not yet been restored during GitOps recovery, no new item is created for it. If an item already exists in a pre-applied ZoneUnit, core preserves it until the matching claim returns or provider cleanup completes.
ZoneUnit.spec.zone does not contain Zone.metadata.uid. Kubernetes assigns metadata.uid, so a ZoneUnit pre-applied for GitOps recovery cannot know the UID of a Zone claim that will be restored later. Provider controllers must not require the Zone UID as desired input.
DNS record identity is still zone, type, and zone-relative name. Same type and zone-relative name always conflicts. Core also applies composition validation forbid-cname-coexistence by default: a type=CNAME item owns the whole record name and cannot coexist with any other record type at the same Zone and name, and a non-CNAME item cannot coexist with a CNAME item at the same Zone and name. A Provider storage version may disable this validation for specific self / other record-set item pairs through zoneUnit.disableValidations[] when the provider intentionally supports that owner-name combination. Route 53 keeps the default CNAME exclusivity. Cloudflare disables forbid-cname-coexistence for CNAME with TXT, MX, and CAA, and keeps conflicts for CNAME with A or AAAA.
Provider storage versions may add stricter owner-name composition checks through zoneUnit.validationRules[]. Cloudflare uses a ZoneUnit validation rule to reject NS coexisting with any other record type at the same owner name because the Cloudflare DNS Records API documents that restriction.
If multiple RecordSet claims target the same record identity, core keeps the existing owner in ZoneUnit.spec.recordSets[] when one exists, including an owner retained with allowed: false. Otherwise, it chooses a deterministic owner by creation timestamp, then namespace/name. Rejected claims get Accepted=False, reason RecordSetConflict, on their own RecordSet.status. Provider controllers do not receive newly rejected claims, but they may receive retained cleanup-ledger items with allowed: false.
ZoneUnit.status is the provider-controller output:
observedGeneration:ZoneUnit.metadata.generationobserved by the provider controller.conditions: resource-level conditions. Initially this containsProgrammed. It does not contain a resource-levelAccepted; acceptance is reported per claim target.provider.state: provider-controller internal state for the whole unit, such as Route 53 pending hosted-zone and record-set change IDs. It is not projected to claim status.zone.conditions: provider acceptance and programming result for theZoneclaim.zone.nameServers: provider-assigned name servers to project toZone.status.nameServers.zone.provider.data: public provider data to project toZone.status.provider.data.zone.provider.state: optional provider-controller internal state for the zone target.recordSets: provider result for accepted ownerRecordSetclaims.recordSets[].recordSetNamespace: ownerRecordSetnamespace matchingspec.recordSets[].recordSets[].recordSetName: ownerRecordSetname matchingspec.recordSets[].recordSets[].observedGeneration: ownerRecordSetgeneration observed inZoneUnit.spec.recordSets[].conditions: provider acceptance and programming result for thatRecordSetclaim.recordSets[].provider.data: public provider data to project toRecordSet.status.provider.data.recordSets[].provider.state: optional provider-controller internal state for that record target.
Provider controllers write ZoneUnit.status only. The Core ZoneUnit Controller projects ZoneUnit.status.zone to Zone.status and ZoneUnit.status.recordSets[] to each owner RecordSet.status. Claim status has status.provider.data for public provider data, but it does not expose provider.state.
Provider-specific status follows a public-data boundary:
provider.datacontains only provider-specific values that help the user operate the resource outside dns-api. Examples include external IDs that can be copied into adoption settings, used to open a provider dashboard, or used in support and troubleshooting workflows.provider.statecontains provider-controller internal state and observed details that are needed for reconciliation but do not help the user take direct action. It is not a cache for exposing provider API responses.- Values already represented in core
spec, corestatus, or provider identity status are not duplicated inprovider.data.
ZoneUnit.status.conditions[Programmed] is True only when the zone target and every accepted record-set target are Programmed=True. It is Unknown if any target is Programmed=Unknown and none is False. It is False if any target is Programmed=False.
False takes precedence over Unknown because the resource-level Programmed condition is also the operational summary for alerting and UI lists. If one target has a known provider error, conflict, or drift, the ZoneUnit as a whole is not programmed even when another target is still being observed. Unknown targets remain visible on their own target conditions, but they must not hide an already known failure from the aggregate condition.
Deletion uses both core and provider finalizers on ZoneUnit:
- Core keeps a finalizer on
ZoneandZoneUnitwhile the external cleanup result must be observed. - Provider controllers keep their own finalizer on
ZoneUnitwhile provider cleanup is in progress. RecordSetis a restrict-style child ofZone. While anyRecordSetstill references a deletingZone, core keeps the matchingZoneUnitactive and does not send the provider cleanup signal. The deletingZonereportsProgrammed=False, reasonProviderChangePending, with a message that the remainingRecordSetresources must be deleted or moved first.- If a
RecordSetthat references a deletingZoneis itself deleting, core keeps or writes itsZoneUnit.spec.recordSets[]item withdeletionRequested=trueuntil provider record-set cleanup completes. - After no
RecordSetreferences the deletingZone, core deletes the matchingZoneUnitbefore removing theZonefinalizer. - If the deleting
Zonehas no remainingRecordSetreferences and the matchingZoneUnitdoes not exist, core removes theZonefinalizer because there is no provider-owned cleanup state to observe. ZoneUnit.metadata.deletionTimestampis the provider cleanup signal. Provider controllers delete or retain external resources according to provider policy, updateZoneUnit.status, and remove their provider finalizer after cleanup completes.- Core removes its
ZoneUnitfinalizer after provider cleanup is complete, then removes theZonefinalizer. - Deleting a
RecordSetsetsrecordSets[].deletionRequested=true. Provider controllers delete the external record target, then report completion. Core removes theRecordSetfinalizer and later removes the item fromZoneUnit.spec.recordSets[].
The validating webhook rejects manual ZoneUnit deletion while the owning Zone exists and is not deleting. The owning Zone is resolved from ZoneUnit.spec.zone.ref. This prevents a user or GitOps controller from sending the provider cleanup signal while the desired Zone claim is still active.
ZoneUnit deletion is allowed when the owning Zone exists and has metadata.deletionTimestamp; this is the normal path started by the Core ZoneUnit Controller during Zone deletion. ZoneUnit deletion is also allowed when the owning Zone is missing, so operators can remove a pre-applied or orphaned GitOps recovery ledger. Core does not delete a ZoneUnit merely because the owning Zone is temporarily missing during GitOps restore.
Provider controllers perform provider cleanup only for ZoneUnit resources that are in their scope and have their provider finalizer or otherwise have provider-owned cleanup to complete. They do not add claim finalizers and do not remove core finalizers. If a deleting ZoneUnit has no provider finalizer and no provider-owned cleanup state, provider controllers may ignore it.
ZoneUnit is not normally GitOps-managed, but it may be pre-applied for disaster recovery before restoring Zone and RecordSet claims. This allows ownership decisions and unresolved conflict results to survive a cluster rebuild. While the Zone is missing, core does not rebuild ZoneUnit.spec; it returns Accepted=Unknown, reason ZoneNotResolved, to matching RecordSet claims that already exist.
If a pre-applied ZoneUnit contains record-set ownership state whose owner claim or matching status material is not resolved yet, core keeps the ZoneUnit.spec.recordSets[] item and reports Accepted=Unknown, reason OwnerStateNotResolved, where a claim status can be written. dns-api does not use the proposal-only reasons OwnerResolutionPending or RecordSetRefNotResolved; the problem is unresolved ownership state during restore, not a separate public reference kind.
RecordSet belongs to a single Zone, so status does not contain an array of parents. Acceptance and programming results are returned in RecordSet.status.conditions.
RecordSet.status fields:
observedGeneration:metadata.generationat the time status was computed.zone: the actual associatedZone.zone.ref: namespace/name reference to the actual associatedZoneresolved by the Core ZoneUnit Controller.provider.data: public provider data projected fromZoneUnit.status.recordSets[].provider.data, validated by Provider versionrecordSet.schemas.statusProviderData.conditions: standard conditions.
RecordSet.status.zone.ref is status output for the controller-resolved parent Zone. It is not merely a copy of the user-written spec.zoneRef object. When the referenced Zone has been resolved, status.zone.ref.namespace and status.zone.ref.name match spec.zoneRef after applying the default namespace. This lets users, UI, and documentation show the actual parent relation without reimplementing defaulting or resolution. If the Zone cannot be resolved, status.zone may be omitted while Accepted=Unknown, reason ZoneNotResolved, is reported.
RecordSet.status.zone contains only resolved Zone identity. RecordSet.status.zone.controllerName is not part of the API and must not be present in CRD schemas, Go API types, generated clients, examples, status projection, UI status reads, or manual documentation. Provider-controller ownership remains discoverable through the resolved Zone, its ZoneClass, and ZoneClass.spec.controllerName; it is not duplicated on RecordSet.status.
Accepted is the composed result of core composition acceptance and provider acceptance. It is Unknown while the controller cannot resolve enough input to decide, such as a missing Zone, missing ZoneClass, unavailable Provider storage version, or missing owner state in a pre-applied ZoneUnit. If the referenced Zone is resolved but Zone.status.conditions[Accepted] is False, the Core ZoneUnit Controller reports the child RecordSet as Accepted=False, reason ZoneNotAccepted; the RecordSet does not keep an older accepted status from before the parent Zone became unusable.
Accepted=False reasons:
InvalidZoneRef:zoneRefcannot be resolved or has an invalid reference shape.InvalidProvider:providercannot be resolved, the Provider version has noRecordSetschema, or the Provider does not supportRecordSet.spec.type.ProviderMismatch:RecordSet.spec.providerdiffers from referencedZoneClass.spec.provider.InvalidIdentityRef: provider-specific identity reference has invalid shape, or the resolved identity is rejected or not usable.InvalidProviderPayload: provider-owned inline payload does not match the selected Provider schema.ProviderConversionFailed: provider-owned inline payload conversion failed permanently or returned invalid storage payload.NotAllowedByZone: does not matchZone.spec.allowedRecordSets.ZoneNotAccepted: referencedZoneis not usable.RecordSetConflict: the same record identity is owned by anotherRecordSetitem inZoneUnit.spec.recordSets[].InvalidAdoption:spec.adoptiondoes not match provider schema.ManagedResourceMismatch: adoption points to a different external record set than the one already managed.DeniedByPolicy: selectedZoneor provider schema preflight policy rejects the resource.
Accepted=Unknown reasons include unresolved material and temporary conversion failures:
IdentityNotResolved: provider-specific identity object or its accepted state is not resolved enough to decide.ProviderConversionUnavailable: Provider conversion webhook is temporarily unavailable, timed out, or returned a retryable failure.
Programmed means the desired state has been reflected to the provider. Provider-specific completion criteria are defined in provider sections. For Route 53, Programmed=True means the last observed RRSet matches the ZoneUnit.spec.recordSets[] item and there is no pendingRecordSetChange affecting that record identity.
Programmed=False reasons:
ProviderChangePending: provider accepted a change and completion is pending.ProviderIdentityNotReady: the provider-specific identity has not passed live credential checks.ProviderChangeDeferred: this reconcile did not submit the change because of provider API batch limits or serialization; it will run in a later reconcile.ExternalResourceNotFound: adoption target external record set does not exist.ExternalResourceMismatch: adoption target external record set exists but does not match spec.ProviderConflict: provider-side record set conflict.ProviderAccessDenied: provider API permission failure.ProviderInvalidRequest: provider API rejected the request as invalid.ProviderUnavailable: provider API outage, rate limit, or temporary failure.ReconcileError: other controller internal error.
RecordSet status validation accepts only Accepted and Programmed condition types. condition.status must be True, False, or Unknown. condition.reason must match the core reason set for the condition type and status, or an entry declared in the selected Provider version recordSet.additionalConditionReasons.
Core RecordSet condition reason combinations:
Accepted=True:Accepted.Accepted=False:InvalidZoneRef,InvalidProvider,ProviderMismatch,InvalidIdentityRef,InvalidProviderPayload,ProviderConversionFailed,NotAllowedByZone,ZoneNotAccepted,RecordSetConflict,InvalidAdoption,ManagedResourceMismatch,DeniedByPolicy.Accepted=Unknown:Reconciling,ZoneNotResolved,ZoneClassNotResolved,IdentityNotResolved,ProviderStorageVersionNotResolved,ProviderConversionUnavailable,OwnerStateNotResolved,ReconcileError.Programmed=True:Programmed.Programmed=False:ProviderChangePending,ProviderIdentityNotReady,ProviderChangeDeferred,ExternalResourceNotFound,ExternalResourceMismatch,ProviderConflict,ProviderAccessDenied,ProviderInvalidRequest,ProviderUnavailable,ReconcileError.Programmed=Unknown:Reconciling,ProviderChangePending,ProviderChangeDeferred,ProviderUnavailable,ReconcileError.
RecordSetConflict means the same Zone, record type, and record name is owned by another RecordSet item in ZoneUnit.spec.recordSets[]. Provider controllers do not receive rejected RecordSet claims.
Kubernetes Events are not the source of truth for RecordSet state. RecordSet.status.conditions and RecordSet.status.provider.data are the source of truth. Events are diagnostic information for users and UI. The Core ZoneUnit Controller normally emits an Event when a condition status or reason changes; it does not repeatedly emit Events for unchanged re-observations.
RecordSet Events use the same four fields as Zone Events: type, reason, message, and related. For condition-derived Events, reason equals the condition reason. Core Event reasons do not include provider prefixes. Provider-specific reasons start with the provider name.
RecordSet Event messages include the minimum needed context: zoneRef, record name, record type, FQDN, and condition reason. Conflict messages include the owner RecordSet when available. Provider API rejection messages may include a sanitized provider error summary when the provider returns a structured error code and message. Provider identity details, secrets, credentials, request headers, raw request bodies, raw response bodies, and raw provider SDK error bodies are not included.
Example messages:
RecordSet type=A name=www in Zone app/example-com was accepted.
RecordSet type=A name=www is not allowed by Zone app/example-com.
RecordSet type=A name=www is already allocated to RecordSet team-a/www.
Provider accepted a change for RecordSet type=A name=www and is waiting for completion.
RecordSet type=A name=www was programmed in Zone app/example-com.
Common RecordSet Events:
| type | reason | related | When emitted |
|---|---|---|---|
Normal |
Accepted |
Zone |
Accepted=True. |
Warning |
InvalidZoneRef |
resolved Zone if any |
invalid zoneRef. |
Warning |
InvalidProvider |
resolved Provider if any |
provider schema or version is invalid. |
Warning |
ProviderMismatch |
Zone |
RecordSet.spec.provider differs from ZoneClass.spec.provider. |
Warning |
NotAllowedByZone |
Zone |
not allowed by Zone.spec.allowedRecordSets. |
Warning |
ZoneNotAccepted |
Zone |
referenced Zone is not usable. |
Warning |
RecordSetConflict |
RecordSet |
record identity is owned by another RecordSet. |
Warning |
InvalidAdoption |
none | adoption payload does not match provider schema. |
Warning |
DeniedByPolicy |
Zone or Provider |
provider or platform policy rejects the resource. |
Warning |
ManagedResourceMismatch |
none | adoption points to a different external record set than the managed one. |
Normal |
ProviderChangePending |
Zone |
provider accepted a change and completion is pending. |
Normal |
ProviderChangeDeferred |
Zone |
batch limit or serialization deferred the change. |
Normal |
Programmed |
Zone |
Programmed=True. |
Warning |
ExternalResourceNotFound |
none | adoption target does not exist. |
Warning |
ExternalResourceMismatch |
none | adoption target exists but does not match spec. |
Warning |
ProviderConflict |
Zone |
provider-side conflict. |
Warning |
ProviderAccessDenied |
provider identity | provider API permission failure. |
Warning |
ProviderInvalidRequest |
Zone |
provider API rejected the request. |
Warning |
ProviderUnavailable |
Zone |
provider API outage, rate limit, or temporary failure. |
Warning |
ReconcileError |
none | controller internal error. |
RecordSet lifecycle helper Events:
| type | reason | related | When emitted |
|---|---|---|---|
Normal |
ExternalResourceAdopted |
Zone |
external record set referenced by adoption is brought under management. |
Normal |
ExternalResourceDeleted |
Zone |
external record set is deleted during RecordSet deletion. |