@wtfalch/foundry 0.19.1 → 0.21.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 (49) hide show
  1. package/dist/bootstrap/declare.d.ts +13 -0
  2. package/dist/bootstrap/declare.js +31 -7
  3. package/dist/bootstrap/versions.d.ts +1 -0
  4. package/dist/index.d.ts +7 -3
  5. package/dist/index.js +4 -2
  6. package/dist/migrations/0046_platform_hc.sql +19 -0
  7. package/dist/plan/cloudflared-connector.d.ts +3 -2
  8. package/dist/plan/cloudflared-connector.js +2 -2
  9. package/dist/plan/deploy-health.js +5 -1
  10. package/dist/plan/diff.js +27 -4
  11. package/dist/plan/edge-access.d.ts +68 -0
  12. package/dist/plan/edge-access.js +123 -0
  13. package/dist/plan/edge-dns.d.ts +89 -0
  14. package/dist/plan/edge-dns.js +227 -0
  15. package/dist/plan/edge-ratelimits.d.ts +54 -0
  16. package/dist/plan/edge-ratelimits.js +79 -0
  17. package/dist/plan/edge-tunnels.d.ts +44 -0
  18. package/dist/plan/edge-tunnels.js +108 -0
  19. package/dist/plan/edge-workers.d.ts +31 -0
  20. package/dist/plan/edge-workers.js +165 -0
  21. package/dist/plan/edge-zones.d.ts +37 -0
  22. package/dist/plan/edge-zones.js +64 -0
  23. package/dist/plan/entry-edge-key.d.ts +10 -0
  24. package/dist/plan/entry-edge-key.js +38 -8
  25. package/dist/plan/executor.d.ts +146 -143
  26. package/dist/plan/executor.js +370 -115
  27. package/dist/plan/health-check-settings.d.ts +47 -0
  28. package/dist/plan/health-check-settings.js +47 -0
  29. package/dist/plan/health-targets.d.ts +86 -0
  30. package/dist/plan/health-targets.js +344 -0
  31. package/dist/plan/index.d.ts +17 -3
  32. package/dist/plan/index.js +9 -2
  33. package/dist/plan/live.d.ts +12 -1
  34. package/dist/plan/plan-apply.d.ts +4 -0
  35. package/dist/plan/plan-apply.js +5 -1
  36. package/dist/plan/platform-diff.d.ts +127 -0
  37. package/dist/plan/platform-diff.js +304 -8
  38. package/dist/plan/platform-providers.d.ts +202 -7
  39. package/dist/plan/platform-providers.js +545 -79
  40. package/dist/plan/providers-readers.d.ts +23 -1
  41. package/dist/plan/providers-readers.js +61 -1
  42. package/dist/plan/rollout.d.ts +9 -8
  43. package/dist/platform.d.ts +14 -1
  44. package/dist/platform.js +1 -0
  45. package/dist/store/postgres.js +8 -0
  46. package/dist/store/schema.d.ts +51 -0
  47. package/dist/store/schema.js +4 -0
  48. package/package.json +2 -1
  49. package/versions.json +2 -1
@@ -2,7 +2,13 @@ import type { OrgMailOptions, OrgMailOrg, OrgMailResult } from '@wtfalch/email/p
2
2
  import type { DeclaredOrg, HostingBuildPack } from '../org.js';
3
3
  import type { DeclaredOrgStore } from '../store.js';
4
4
  import { type BrokerPort } from './broker-entries.js';
5
+ import { type DeployHealthRuntime } from './deploy-health.js';
5
6
  import { type StepAction } from './diff.js';
7
+ import { type AccessPort, type EdgePlatformAccess } from './edge-access.js';
8
+ import { type DnsPort, type EdgePlatformDns } from './edge-dns.js';
9
+ import { type EdgePlatformRateLimits, type RateLimitPort } from './edge-ratelimits.js';
10
+ import { type EdgePlatformTunnels, type TunnelPort } from './edge-tunnels.js';
11
+ import { type EdgePlatformZones, type ZonePort } from './edge-zones.js';
6
12
  import { type LoginHostBindingsPort } from './login-host.js';
7
13
  import { type MailCopyPort } from './mail-copy.js';
8
14
  import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
@@ -379,6 +385,8 @@ export interface CoolifyPort {
379
385
  enabled: boolean;
380
386
  save_s3: boolean;
381
387
  s3_storage_uuid?: string;
388
+ /** The databases this schedule dumps, comma separated. Empty means the resource's first database. */
389
+ databases_to_backup?: string | null;
382
390
  }>>;
383
391
  /**
384
392
  * #257: schedules dumps of a database to the registered S3 storage --
@@ -415,8 +423,10 @@ export interface CoolifyPort {
415
423
  }>;
416
424
  }
417
425
  /**
418
- * The Cloudflare calls this executor makes, shaped to match
419
- * `@wtfalch/providers/cloudflare`'s `CloudflareClient` method-for-method.
426
+ * The Cloudflare zone calls the platform steps make directly, shaped to match
427
+ * `@wtfalch/providers/cloudflare`'s `CloudflareClient` method-for-method. They
428
+ * run before edge exists (edge's own step finds the apex zone), so they cannot
429
+ * go through edge. An org's steps use {@link ZonePort}, which does.
420
430
  */
421
431
  export interface CloudflarePort {
422
432
  findZoneByApex(domain: string): Promise<{
@@ -427,132 +437,6 @@ export interface CloudflarePort {
427
437
  id: string;
428
438
  name: string;
429
439
  }>;
430
- findTunnelByName(name: string): Promise<{
431
- id: string;
432
- name: string;
433
- } | undefined>;
434
- createTunnel(name: string): Promise<{
435
- id: string;
436
- name: string;
437
- }>;
438
- putTunnelConfig(tunnelId: string, config: unknown): Promise<unknown>;
439
- /**
440
- * The tunnel's connector token (#197) -- a `cloudflared` container reads
441
- * this at boot to dial into the tunnel. A live credential: matches
442
- * `@wtfalch/providers`' `CloudflareClient.getTunnelToken` method-for-method.
443
- * A caller must hand this straight to `bulkUpsertEnvs` and nowhere else --
444
- * never a log, an error, a return value or a recorded scope id (see
445
- * `cloudflared-connector.ts`'s `ensureCloudflaredConnector`).
446
- */
447
- getTunnelToken(tunnelId: string): Promise<string>;
448
- /** `FoundryPlanApply.destroy`'s teardown of the org's own Cloudflare tunnel. */
449
- deleteTunnel(tunnelId: string): Promise<void>;
450
- /** `cloudflareAccess` finds the org's Access app by domain before creating one (#84). */
451
- listAccessApps(): Promise<Array<{
452
- id: string;
453
- name: string;
454
- domain: string;
455
- }>>;
456
- createAccessApp(app: {
457
- name: string;
458
- domain: string;
459
- type: string;
460
- }): Promise<{
461
- id: string;
462
- }>;
463
- listAccessPolicies(appId: string): Promise<Array<{
464
- id: string;
465
- name: string;
466
- include?: unknown[];
467
- }>>;
468
- createAccessPolicy(appId: string, policy: {
469
- name: string;
470
- decision: string;
471
- include: unknown[];
472
- }): Promise<{
473
- id: string;
474
- }>;
475
- /**
476
- * `cloudflareAccess` resolves a `kind: 'service-token'` rule's named
477
- * token to its id (#246) -- the token itself is a live Cloudflare
478
- * resource this package never mints; `undefined` when no token by that
479
- * name exists yet.
480
- */
481
- findServiceTokenByName(name: string): Promise<{
482
- id: string;
483
- } | undefined>;
484
- deleteAccessApp(appId: string): Promise<void>;
485
- /**
486
- * `FoundryPlanApply.destroy`'s teardown of any DNS record foundry created
487
- * for the org -- `destroy`'s `cloudflare:tunnel` case uses it to remove
488
- * every CNAME {@link publishTunnelDnsRecords} published (#236).
489
- */
490
- deleteDnsRecord(zoneId: string, recordId: string): Promise<void>;
491
- /** `coolify:mail`'s lookup of an existing MX/TXT record before upserting it, filtered by type so a stale record of a different type is never mistaken for a match. */
492
- findDnsRecord(zoneId: string, name: string, type?: string): Promise<Array<{
493
- id: string;
494
- type: string;
495
- content: string;
496
- name: string;
497
- }>>;
498
- /**
499
- * #236: `destroy`'s `cloudflare:tunnel` case and
500
- * {@link FoundryStepExecutor.deleteStaleTunnelDnsRecords} each fall back to
501
- * this for a domain recorded before
502
- * {@link CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY} existed, with no
503
- * hostname list to look up instead -- the one case left that must still
504
- * see every CNAME in the zone, not one matched by name. Round 3: this is
505
- * no longer either caller's normal path, since the real adapter
506
- * (`@wtfalch/providers`' `cloudflare.ts`) asks for one page of 100 and
507
- * never walks `result_info.total_pages`, so a zone with more records than
508
- * that would silently lose everything past the first page.
509
- */
510
- listDnsRecords(zoneId: string, opts?: {
511
- type?: string;
512
- name?: string;
513
- }): Promise<Array<{
514
- id: string;
515
- type: string;
516
- content: string;
517
- name: string;
518
- }>>;
519
- createDnsRecord(zoneId: string, rec: {
520
- type: string;
521
- name: string;
522
- content: string;
523
- proxied?: boolean;
524
- ttl?: number;
525
- /** Required by Cloudflare for an `MX` record. */
526
- priority?: number;
527
- }): Promise<{
528
- id: string;
529
- name: string;
530
- content: string;
531
- }>;
532
- updateDnsRecord(zoneId: string, recordId: string, rec: {
533
- type: string;
534
- name: string;
535
- content: string;
536
- proxied?: boolean;
537
- ttl?: number;
538
- priority?: number;
539
- }): Promise<{
540
- id: string;
541
- name: string;
542
- content: string;
543
- }>;
544
- /**
545
- * Escape hatch (mirrors `CoolifyPort.raw`'s own precedent above) --
546
- * `@wtfalch/providers` 0.5.0's `CloudflareClient` has no typed method for
547
- * Health Checks or zone rulesets/rate-limiting rules
548
- * (https://github.com/wtfalch/foundry/issues/43), only this public,
549
- * generic `request<T>(method, path, body?, missingOkay?)`, which
550
- * this port names directly rather than inventing a narrower method no real
551
- * client could structurally satisfy. `cloudflareRateLimit` below is its one
552
- * caller today. `missingOkay` makes a 404 resolve to `undefined` instead of
553
- * throwing, matching `CloudflareClient`'s own real behavior exactly.
554
- */
555
- request<T>(method: string, path: string, body?: unknown, missingOkay?: boolean): Promise<T>;
556
440
  }
557
441
  /**
558
442
  * One Cloudflare Workers rate limiting binding, Cloudflare's own "simple"
@@ -569,7 +453,8 @@ export interface WorkersRateLimitBinding {
569
453
  };
570
454
  }
571
455
  /**
572
- * The Cloudflare Workers calls `cloudflareWorker` makes (#318, F8): deploys
456
+ * The Workers calls `cloudflareWorker` makes (#318, F8), served by edge's
457
+ * `platform.workers` through `createEdgeWorkers` (`edge-workers.ts`): deploys
573
458
  * a plain Worker from an entry's `worker` declaration and routes it on the
574
459
  * org's own zone. Optional on {@link FoundryStepExecutorPorts} -- a host
575
460
  * with no Workers rights on its Cloudflare token (the issue's own "Human
@@ -583,15 +468,13 @@ export interface WorkersRateLimitBinding {
583
468
  * package (estate DECISIONS.md, 2026-10-04, "offline.tf Worker setup" --
584
469
  * "Foundry consumes and deploys the artifact through the provider
585
470
  * boundary... No manual build step and no source checkout/bundling in
586
- * Foundry"). The real adapter refuses a fetched body whose digest does not
587
- * match. The upload itself is a multipart request Cloudflare's API wants (a
588
- * `metadata` part naming `main_module` and `bindings`, plus the module) --
589
- * `CloudflarePort.request` sends JSON only, so this port names the call
590
- * directly rather than forcing it through that escape hatch.
471
+ * Foundry"). The host hands `createEdgeWorkers` a `fetchArtifact(url, sha256)`
472
+ * that fetches and verifies the bytes; the adapter sends them to edge as
473
+ * base64 with the digest, and edge checks the digest again.
591
474
  *
592
475
  * `deleteRoute`/`deleteScript` are idempotent: deleting a route or script
593
476
  * that is already gone resolves rather than throwing, the same contract
594
- * `CloudflarePort.deleteDnsRecord`/`deleteAccessApp` give their own
477
+ * `DnsPort.deleteDnsRecord`/`deleteAccessApp` give their own
595
478
  * already-gone case via `isNotFoundError` at the call site -- here it is
596
479
  * the port's own job, since `cloudflareWorker` deletes a route or script by
597
480
  * id/name it already recorded, with nothing live to re-check first.
@@ -1373,6 +1256,37 @@ export interface EdgePort {
1373
1256
  secret: string;
1374
1257
  }>;
1375
1258
  revokeKey(organisationId: string, keyId: string): Promise<void>;
1259
+ /**
1260
+ * F4.dns: edge's platform DNS client (`createEdgeClient({ baseUrl, key
1261
+ * }).platform.dns`), built with the same platform key that mints and
1262
+ * revokes. Foundry reads and writes every DNS record through it.
1263
+ */
1264
+ dns: EdgePlatformDns;
1265
+ /**
1266
+ * F4.tunnels: edge's platform tunnel client (`createEdgeClient({ baseUrl,
1267
+ * key }).platform.tunnels`), built with the same platform key. Foundry
1268
+ * creates, configures, reads the token of and deletes every tunnel through it.
1269
+ */
1270
+ tunnels: EdgePlatformTunnels;
1271
+ /**
1272
+ * F4.access: edge's platform Access client (`createEdgeClient({ baseUrl,
1273
+ * key }).platform.access`), built with the same platform key. Foundry
1274
+ * finds, creates, adopts and deletes every Access app, and adds its
1275
+ * policies, through it.
1276
+ */
1277
+ access: EdgePlatformAccess;
1278
+ /**
1279
+ * F4.ratelimits: edge's platform rate limiting client (`createEdgeClient({
1280
+ * baseUrl, key }).platform.ratelimits`), built with the same platform key.
1281
+ * Foundry lists, adopts and creates every rate limiting rule through it.
1282
+ */
1283
+ ratelimits: EdgePlatformRateLimits;
1284
+ /**
1285
+ * F4.zones: edge's platform zones client (`createEdgeClient({ baseUrl,
1286
+ * key }).platform.zones`), built with the same platform key. Foundry
1287
+ * looks up and creates every org zone through it.
1288
+ */
1289
+ zones: EdgePlatformZones;
1376
1290
  /** Refuses a label any hosting entry of `organisationId` already holds. Rerun whenever the org's own label set changes. */
1377
1291
  setReservedLabels(organisationId: string, labels: string[]): Promise<void>;
1378
1292
  }
@@ -1420,7 +1334,6 @@ export interface PlatformOrgPortSource {
1420
1334
  }
1421
1335
  export interface FoundryStepExecutorPorts {
1422
1336
  coolify: CoolifyPort;
1423
- cloudflare: CloudflarePort;
1424
1337
  zitadel: ZitadelPort;
1425
1338
  mail: MailPort;
1426
1339
  lettermint: LettermintPort;
@@ -1455,6 +1368,31 @@ export interface FoundryStepExecutorPorts {
1455
1368
  * throw.
1456
1369
  */
1457
1370
  edge?: EdgePort;
1371
+ /**
1372
+ * F4.dns: overrides the DNS calls, which otherwise go through `edge.dns`
1373
+ * (see {@link EdgePort.dns}). Kept for a host or a test that supplies its own.
1374
+ */
1375
+ dns?: DnsPort;
1376
+ /**
1377
+ * F4.tunnels: overrides the tunnel calls, which otherwise go through
1378
+ * `edge.tunnels` (see {@link EdgePort.tunnels}). Kept for a host or a test that supplies its own.
1379
+ */
1380
+ tunnels?: TunnelPort;
1381
+ /**
1382
+ * F4.access: overrides the Access calls, which otherwise go through
1383
+ * `edge.access` (see {@link EdgePort.access}). Kept for a host or a test that supplies its own.
1384
+ */
1385
+ access?: AccessPort;
1386
+ /**
1387
+ * F4.ratelimits: overrides the rate limiting rule calls, which otherwise go through
1388
+ * `edge.ratelimits` (see {@link EdgePort.ratelimits}). Kept for a host or a test that supplies its own.
1389
+ */
1390
+ ratelimits?: RateLimitPort;
1391
+ /**
1392
+ * F4.zones: overrides the zone calls, which otherwise go through
1393
+ * `edge.zones` (see {@link EdgePort.zones}). Kept for a host or a test that supplies its own.
1394
+ */
1395
+ zones?: ZonePort;
1458
1396
  /**
1459
1397
  * #177: builds the files, AI and jobs ports from the platform's addresses
1460
1398
  * and held keys. A port set above overrides it (tests); with neither, the
@@ -1516,6 +1454,17 @@ export interface FoundryStepExecutorPorts {
1516
1454
  * passes the same store instance it constructs `FoundryPlanApply` with.
1517
1455
  */
1518
1456
  declaredOrgStore: Pick<DeclaredOrgStore, 'findOrgIdForZone'>;
1457
+ /**
1458
+ * #543: clock, sleep and `fetch` for the health-check wait every company
1459
+ * app's own redeploy now goes through (`deploy-health.ts`'s
1460
+ * `waitForNewDeployment`) -- the same override shape `platform-providers.ts`'s
1461
+ * own `PlatformMailDeps.runtime`/`PlatformArchonDeps.runtime` already take,
1462
+ * here on one shared port instead of one per platform step since this
1463
+ * executor is the one class every company-app step runs through. Optional:
1464
+ * without it, {@link defaultSleep} (plain `setTimeout`) and the real
1465
+ * `fetch`/`Date.now` are used.
1466
+ */
1467
+ runtime?: DeployHealthRuntime;
1519
1468
  }
1520
1469
  /**
1521
1470
  * Thrown by name for a step this executor deliberately does not implement:
@@ -1527,6 +1476,26 @@ export interface FoundryStepExecutorPorts {
1527
1476
  export declare class NotBuiltError extends Error {
1528
1477
  constructor(step: string, detail: string);
1529
1478
  }
1479
+ /**
1480
+ * `cms.<domain>` -- the cms studio's own hostname (`cmsSite` below), apart
1481
+ * from the public site itself at `org.domain` (used directly there). Called
1482
+ * from `cmsSite` and from `zitadelApp` for the fixed `cms` client (#139),
1483
+ * both of which only run once `org.site.enabled` is true, so `org.domain`
1484
+ * is non-null by the same `validateSite` guarantee
1485
+ * {@link tunnelIngressHostnames}'s own doc comment cites.
1486
+ */
1487
+ export declare function cmsHostnameFor(org: DeclaredOrg): string;
1488
+ /**
1489
+ * #145's "Decided" (2026-09-27): the calls service's own public hostname.
1490
+ * `calls.usesDomain: false` (or no `domain`) is served flat, like
1491
+ * `<slug>-stage.oikos.host` and `<slug>-pr-<n>.oikos.host` (`../slug.ts`) --
1492
+ * the shared tunnel's wildcard `*.oikos.host` DNS record already reaches it,
1493
+ * so nothing changes in `scripts/shared-ingress.mjs`. `calls.usesDomain: true`
1494
+ * gets `calls.<domain>` instead, on its own per-org tunnel
1495
+ * ({@link tunnelIngressHostnames}), the same subdomain-of-the-org's-own-domain
1496
+ * shape {@link cmsHostnameFor} uses for `cms.<domain>`.
1497
+ */
1498
+ export declare function callsHostnameFor(org: DeclaredOrg): string;
1530
1499
  /**
1531
1500
  * The health URLs an outside prober should call for this org (#43): one per
1532
1501
  * app foundry runs for it. A hosting org's app answers at its own hostname,
@@ -1579,6 +1548,12 @@ export declare const databaseStartWait: {
1579
1548
  };
1580
1549
  export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer {
1581
1550
  private readonly ports;
1551
+ private edgeDns;
1552
+ private edgeTunnels;
1553
+ private edgeAccess;
1554
+ private edgeRateLimits;
1555
+ private edgeZones;
1556
+ private edgeWriteSlot;
1582
1557
  constructor(ports: FoundryStepExecutorPorts);
1583
1558
  execute(action: StepAction, context: StepExecutionContext): Promise<{
1584
1559
  resourceId?: string;
@@ -1615,12 +1590,10 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1615
1590
  * gate), so `org.domain` is set and that zone is the org's alone, never
1616
1591
  * shared with another org the way `oikos.host` is.
1617
1592
  *
1618
- * Rate limiting rules attach to a zone's `http_ratelimit` phase entry
1619
- * point ruleset, one `GET`/`PUT` pair per zone (`@wtfalch/providers` 0.5.0
1620
- * has no typed method for either -- `CloudflarePort.request`'s own doc
1621
- * comment) -- so "find by name" here means reading that one ruleset's
1622
- * `rules[]` and matching `RATE_LIMIT_RULE_DESCRIPTION`, the same shape
1623
- * `cloudflareAccess` above finds its own Access app by name. The Free
1593
+ * Rate limiting rules go through edge (`RateLimitPort`) -- so "find by
1594
+ * name" here means listing the zone's rules and matching
1595
+ * `RATE_LIMIT_RULE_DESCRIPTION`, the same shape `cloudflareAccess` above
1596
+ * finds its own Access app by name. The Free
1624
1597
  * plan allows exactly one rule per zone, so a rule already there under a
1625
1598
  * different description is refused, never adopted or replaced, mirroring
1626
1599
  * `cloudflareAccess`'s own refusal when a foreign Access app already
@@ -2419,6 +2392,36 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2419
2392
  /** #177: the host's own port when it supplies one, else the platform's, built from its address and held key. */
2420
2393
  private filesPort;
2421
2394
  private aiPort;
2395
+ /**
2396
+ * Every DNS record call runs through edge (`edge.dns`, the platform
2397
+ * credential), adopting live records before it first changes them. `dns` on
2398
+ * the ports is a seam for a host or test that supplies its own {@link DnsPort}.
2399
+ */
2400
+ private dnsPort;
2401
+ /**
2402
+ * Every tunnel call runs through edge (`edge.tunnels`, the platform
2403
+ * credential), adopting a live tunnel before it first configures, reads the
2404
+ * token of or deletes it. Shares edge's write slot with {@link dnsPort}.
2405
+ */
2406
+ private tunnelPort;
2407
+ /**
2408
+ * Every Access call runs through edge (`edge.access`, the platform
2409
+ * credential), adopting a live app before it first adds a policy to or
2410
+ * deletes it. Shares edge's write slot with {@link dnsPort}.
2411
+ */
2412
+ private accessPort;
2413
+ /**
2414
+ * Every rate limiting rule call runs through edge (`edge.ratelimits`, the
2415
+ * platform credential), adopting the live rule before it first creates it.
2416
+ * Shares edge's write slot with {@link dnsPort}.
2417
+ */
2418
+ private rateLimitPort;
2419
+ /**
2420
+ * Every zone lookup and create runs through edge (`edge.zones`, the
2421
+ * platform credential), adopting a live zone before it first creates it.
2422
+ * Shares edge's write slot with {@link dnsPort}.
2423
+ */
2424
+ private zonePort;
2422
2425
  private jobsPort;
2423
2426
  private jobsRegistration;
2424
2427
  /** `deliverSecret`, tried up to {@link JOBS_DELIVERY_ATTEMPTS} times; true once one attempt succeeds. */
@@ -2647,7 +2650,7 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2647
2650
  * a record someone proxied by hand is put back. An `MX` record carries the
2648
2651
  * priority it was given, which Cloudflare requires.
2649
2652
  *
2650
- * Found by an exact name+type match (`CloudflarePort.findDnsRecord`'s own
2653
+ * Found by an exact name+type match (`DnsPort.findDnsRecord`'s own
2651
2654
  * doc comment) so a stale record of a different type is never taken for a
2652
2655
  * match. A name can hold several `TXT` records (an apex often holds a site
2653
2656
  * verification beside SPF), so a `TXT` record only replaces one that