@wtfalch/foundry 0.33.1 → 0.35.1

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 (106) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +1 -1
  2. package/dist/bootstrap/bootstrap.js +49 -11
  3. package/dist/bootstrap/declare.d.ts +12 -5
  4. package/dist/bootstrap/declare.js +33 -18
  5. package/dist/bootstrap/versions.d.ts +24 -0
  6. package/dist/bootstrap/versions.js +46 -0
  7. package/dist/calls-sfu.d.ts +10 -0
  8. package/dist/calls-sfu.js +44 -3
  9. package/dist/index.d.ts +21 -8
  10. package/dist/index.js +11 -4
  11. package/dist/migrations/0051_org_services.sql +13 -0
  12. package/dist/migrations/0052_platform_mail_relay.sql +8 -0
  13. package/dist/migrations/0053_heartbeats_provider.sql +9 -0
  14. package/dist/migrations/0053_org_version_pin.sql +6 -0
  15. package/dist/org.d.ts +76 -12
  16. package/dist/org.js +136 -20
  17. package/dist/plan/backup-gate.d.ts +93 -0
  18. package/dist/plan/backup-gate.js +261 -0
  19. package/dist/plan/broker-entries.d.ts +9 -1
  20. package/dist/plan/broker-entries.js +20 -3
  21. package/dist/plan/build-record.d.ts +56 -0
  22. package/dist/plan/build-record.js +109 -0
  23. package/dist/plan/calls-matrix.d.ts +9 -0
  24. package/dist/plan/calls-matrix.js +18 -1
  25. package/dist/plan/cloudflared-connector.d.ts +7 -1
  26. package/dist/plan/cloudflared-connector.js +15 -2
  27. package/dist/plan/company-backups.d.ts +31 -0
  28. package/dist/plan/company-backups.js +101 -0
  29. package/dist/plan/deploy-health.d.ts +12 -2
  30. package/dist/plan/deploy-health.js +3 -1
  31. package/dist/plan/destinations.d.ts +73 -0
  32. package/dist/plan/destinations.js +217 -0
  33. package/dist/plan/diff.d.ts +46 -8
  34. package/dist/plan/diff.js +367 -126
  35. package/dist/plan/edge-access.d.ts +15 -0
  36. package/dist/plan/edge-access.js +21 -2
  37. package/dist/plan/executor.d.ts +276 -5
  38. package/dist/plan/executor.js +1111 -109
  39. package/dist/plan/health-check-settings.d.ts +28 -0
  40. package/dist/plan/health-check-settings.js +36 -0
  41. package/dist/plan/health-gaps.d.ts +33 -0
  42. package/dist/plan/health-gaps.js +89 -0
  43. package/dist/plan/health-targets.d.ts +1 -3
  44. package/dist/plan/health-targets.js +36 -36
  45. package/dist/plan/held-key-refs.d.ts +15 -0
  46. package/dist/plan/held-key-refs.js +15 -0
  47. package/dist/plan/hostnames.d.ts +10 -0
  48. package/dist/plan/hostnames.js +14 -0
  49. package/dist/plan/index.d.ts +19 -6
  50. package/dist/plan/index.js +10 -3
  51. package/dist/plan/live.d.ts +11 -2
  52. package/dist/plan/live.js +16 -2
  53. package/dist/plan/locked-hostnames.d.ts +7 -3
  54. package/dist/plan/locked-hostnames.js +25 -4
  55. package/dist/plan/login-host.d.ts +61 -12
  56. package/dist/plan/login-host.js +99 -11
  57. package/dist/plan/mail-relay.d.ts +139 -0
  58. package/dist/plan/mail-relay.js +400 -0
  59. package/dist/plan/network-move.d.ts +20 -0
  60. package/dist/plan/network-move.js +41 -0
  61. package/dist/plan/oikos-ingress.d.ts +111 -0
  62. package/dist/plan/oikos-ingress.js +231 -0
  63. package/dist/plan/org-names.d.ts +30 -0
  64. package/dist/plan/org-names.js +108 -0
  65. package/dist/plan/own-diff.d.ts +170 -0
  66. package/dist/plan/own-diff.js +440 -0
  67. package/dist/plan/own-dns.d.ts +6 -0
  68. package/dist/plan/own-dns.js +15 -0
  69. package/dist/plan/own-hostnames.d.ts +27 -0
  70. package/dist/plan/own-hostnames.js +24 -0
  71. package/dist/plan/own-limits.d.ts +213 -0
  72. package/dist/plan/own-limits.js +232 -0
  73. package/dist/plan/own-services.d.ts +193 -0
  74. package/dist/plan/own-services.js +169 -0
  75. package/dist/plan/own-sfu.d.ts +69 -0
  76. package/dist/plan/own-sfu.js +256 -0
  77. package/dist/plan/plan-apply.d.ts +121 -10
  78. package/dist/plan/plan-apply.js +357 -18
  79. package/dist/plan/platform-diff.d.ts +155 -11
  80. package/dist/plan/platform-diff.js +271 -66
  81. package/dist/plan/platform-org.d.ts +107 -4
  82. package/dist/plan/platform-org.js +175 -3
  83. package/dist/plan/platform-providers.d.ts +474 -81
  84. package/dist/plan/platform-providers.js +1407 -560
  85. package/dist/plan/proxy-labels.d.ts +57 -11
  86. package/dist/plan/proxy-labels.js +94 -14
  87. package/dist/plan/run-options.d.ts +19 -0
  88. package/dist/plan/run-options.js +46 -0
  89. package/dist/plan/sign-in-org.d.ts +1 -1
  90. package/dist/plan/sign-in-org.js +20 -2
  91. package/dist/plan/zitadel-login-policy.d.ts +1 -1
  92. package/dist/plan/zitadel-login-policy.js +77 -9
  93. package/dist/plan/zitadel-system-api.d.ts +22 -1
  94. package/dist/plan/zitadel-system-api.js +49 -8
  95. package/dist/platform.d.ts +40 -0
  96. package/dist/platform.js +16 -0
  97. package/dist/provision.d.ts +2 -0
  98. package/dist/provision.js +3 -1
  99. package/dist/slug.d.ts +3 -1
  100. package/dist/slug.js +26 -3
  101. package/dist/store/postgres.js +93 -2
  102. package/dist/store/schema.d.ts +87 -0
  103. package/dist/store/schema.js +9 -0
  104. package/package.json +3 -3
  105. package/versions.digests.json +11 -0
  106. package/versions.json +3 -1
@@ -2,9 +2,12 @@ import { createHash } from 'node:crypto';
2
2
  import { VERSIONS } from '../bootstrap/versions.js';
3
3
  import { APP_ENTRY_KEY } from '../org.js';
4
4
  import { VERSION_CHANGE_KIND, } from '../plan.js';
5
+ import { buildDriftNotes } from './build-record.js';
5
6
  import { versionChangeNeedsConfirmation } from './confirm-upgrade.js';
6
7
  import { ACCESS_PROVIDER_RECORD_KEY } from './gates.js';
7
8
  import { legacyAppHostname } from './hostnames.js';
9
+ import { PLATFORM_MAIL_RELAY_KEY, planMailRelay } from './mail-relay.js';
10
+ import { planOikosIngress } from './oikos-ingress.js';
8
11
  import { callsSfuAppliedFingerprint } from './platform-sfu.js';
9
12
  import { ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY, signInDomainBlockedText } from './sign-in-org.js';
10
13
  import { withStepSystem } from './step-system.js';
@@ -218,15 +221,16 @@ export const PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY = 'archon_applied_auth_o
218
221
  */
219
222
  export const PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY = 'archon_applied_rolling_update';
220
223
  /**
221
- * The sign-in org Archon accepts: the platform owner company's own
222
- * ({@link PLATFORM_OWNER_ORG_SCOPE_KEY}), else the platform's `foundry` org for
223
- * a platform with no owner company declared. Undefined when neither is recorded.
224
+ * The owner company's sign-in hostname Archon was last registered under, and
225
+ * the owner's files host (`<internalUrl>|<audience>`, or `none`) its env was
226
+ * last written with. `planArchon` plans the step again when either differs
227
+ * from the owner's current ones (a marker never recorded reads as `none`).
224
228
  */
225
- export function archonAuthOrganizationId(recorded) {
226
- const owner = recorded[PLATFORM_OWNER_ORG_SCOPE_KEY];
227
- if (owner && owner !== 'none')
228
- return owner;
229
- return recorded[PLATFORM_ZITADEL_ORG_SCOPE_KEY] || undefined;
229
+ export const PLATFORM_ARCHON_APPLIED_AUTH_HOSTNAME_SCOPE_KEY = 'archon_applied_auth_hostname';
230
+ export const PLATFORM_ARCHON_APPLIED_FILES_SCOPE_KEY = 'archon_applied_files';
231
+ /** The value {@link PLATFORM_ARCHON_APPLIED_FILES_SCOPE_KEY} records for the owner's files host. */
232
+ export function archonFilesMarker(files) {
233
+ return files ? `${files.internalUrl}|${files.audience}` : 'none';
230
234
  }
231
235
  /**
232
236
  * #175: set to '1' before the step points the tunnel at `http_status:404`
@@ -482,6 +486,26 @@ export const PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY = 'mail_applied_git_ref';
482
486
  * no key and creates nothing in the sign-in server.
483
487
  */
484
488
  export const PLATFORM_MAIL_ROUTE_SCOPE_KEY = 'mail_route_applied';
489
+ /**
490
+ * The host ports the platform Stalwart publishes, as the mapping string the
491
+ * last apply set on its app. Without the shared relay it binds SMTP port 25
492
+ * itself; with `mail.relay` it keeps only submission, because the relay owns
493
+ * port 25. A missing record means the mapping it was created with, `25:25,...`.
494
+ */
495
+ export const PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY = 'mail_host_ports_applied';
496
+ export const PLATFORM_MAIL_HOST_PORTS_DIRECT = '25:25,465:465,587:587';
497
+ export const PLATFORM_MAIL_HOST_PORTS_RELAY = '465:465,587:587';
498
+ export function platformMailHostPorts(platform) {
499
+ return platform.mail.relay === true
500
+ ? PLATFORM_MAIL_HOST_PORTS_RELAY
501
+ : PLATFORM_MAIL_HOST_PORTS_DIRECT;
502
+ }
503
+ function mailPortsApplied(platform, recordedIds) {
504
+ return ((recordedIds[PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY] ?? PLATFORM_MAIL_HOST_PORTS_DIRECT) ===
505
+ platformMailHostPorts(platform));
506
+ }
507
+ export { PLATFORM_MAIL_RELAY_KEY };
508
+ export { PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY, } from './mail-relay.js';
485
509
  export const PLATFORM_MAIL_ROUTE_WANTED = 'local-first';
486
510
  /**
487
511
  * #177: where the mail server answers on the Coolify network
@@ -588,8 +612,78 @@ export function serviceDbEnvAppliedScopeKey(service) {
588
612
  export function zitadelAppIdTokenUserinfoAppliedScopeKey(appKey) {
589
613
  return `zitadel_app_id_token_userinfo_applied.${appKey}`;
590
614
  }
591
- /** The ZITADEL app id of Archon's sign-in client in the platform's own ZITADEL. */
615
+ /**
616
+ * Archon's own sign-in organisation, in the owner company's ZITADEL instance
617
+ * and apart from the organisation that holds the company's people (and Boule's
618
+ * sign-in). Made by the `zitadel:archon-org` step, which runs before
619
+ * `platform:archon`. The first six keys below are the step's own progress:
620
+ * each is recorded only after its call succeeded.
621
+ */
622
+ export const PLATFORM_ARCHON_ORG_SCOPE_KEY = 'zitadel_platform_archon_org';
623
+ /** The primary domain registered on Archon's sign-in organisation. */
624
+ export const PLATFORM_ARCHON_ORG_DOMAIN_SCOPE_KEY = 'zitadel_platform_archon_org_domain';
625
+ /** The domain the sign-in server refused to make primary; set instead of failing, reported as blocked. */
626
+ export const PLATFORM_ARCHON_ORG_DOMAIN_BLOCKED_SCOPE_KEY = 'zitadel_platform_archon_org_domain_blocked';
627
+ /** The Archon org id once self-registration is closed on it. */
628
+ export const PLATFORM_ARCHON_ORG_REGISTRATION_CLOSED_SCOPE_KEY = 'zitadel_platform_archon_org_registration_closed';
629
+ /** The Archon org id once its login policy forces a second factor. */
630
+ export const PLATFORM_ARCHON_ORG_POLICY_SCOPE_KEY = 'zitadel_platform_archon_org_policy';
631
+ /** The first person's user id in the Archon org (`none` when no owner is declared), recorded once the invite was mailed. */
632
+ export const PLATFORM_ARCHON_OWNER_INVITE_SCOPE_KEY = 'zitadel_platform_archon_owner_invite';
633
+ /** The Archon org the recorded invite was made in. */
634
+ export const PLATFORM_ARCHON_OWNER_INVITE_ORG_SCOPE_KEY = 'zitadel_platform_archon_owner_invite_org';
635
+ /** The name of the Archon sign-in organisation: `<owner slug>-archon`. */
636
+ export function archonOrgName(ownerSlug) {
637
+ return `${ownerSlug}-archon`;
638
+ }
639
+ /** The primary domain of the Archon sign-in organisation: `archon.` before the owner's own sign-in domain. */
640
+ export function archonOrgDomain(ownerSignInDomain) {
641
+ return `archon.${ownerSignInDomain}`;
642
+ }
643
+ /**
644
+ * The CI organisation id the last Archon apply wrote as `CI_ORGANIZATION_ID`,
645
+ * and the `ARCHON_FIRST_OWNER_LINK` value it wrote (empty when none).
646
+ * `planArchon` plans the step again when either differs from what is wanted.
647
+ */
648
+ export const PLATFORM_ARCHON_APPLIED_CI_ORG_SCOPE_KEY = 'archon_applied_ci_org';
649
+ export const PLATFORM_ARCHON_APPLIED_FIRST_OWNER_LINK_SCOPE_KEY = 'archon_applied_first_owner_link';
650
+ /**
651
+ * `<archonUserId>:<ciUserId>`: the first person in both organisations, which
652
+ * Archon seeds its link table from. Empty when either id is not known (no
653
+ * owner declared, or the CI-side account not recorded yet).
654
+ */
655
+ export function archonFirstOwnerLink(recordedIds, ownerSignIn) {
656
+ const archonUserId = recordedIds[PLATFORM_ARCHON_OWNER_INVITE_SCOPE_KEY];
657
+ const ciUserId = ownerSignIn?.firstOwnerUserId;
658
+ if (!archonUserId || archonUserId === 'none' || !ciUserId || ciUserId === 'none')
659
+ return '';
660
+ return `${archonUserId}:${ciUserId}`;
661
+ }
662
+ /**
663
+ * The one-time-PIN provider id the Archon Access app was last made with.
664
+ * Drift is read against it, because a plan must not ask edge to find or make
665
+ * the provider.
666
+ */
667
+ export const PLATFORM_ARCHON_ACCESS_PIN_SCOPE_KEY = 'cloudflare_platform_archon_access_pin';
668
+ /**
669
+ * The Archon org the `AUTH_APP_KEY` in Archon's env was minted for. A key
670
+ * minted for another org (the CI org, before the move) is replaced by a fresh
671
+ * one, so the key the CI-org broker entry held stops being Archon's.
672
+ */
673
+ export const PLATFORM_ARCHON_APP_KEY_ORG_SCOPE_KEY = 'archon_app_key_org';
674
+ /**
675
+ * `<org>|<project>|<app>` of Archon's old sign-in client while it still waits
676
+ * to be deleted. Set before the new client's ids overwrite the recorded ones,
677
+ * cleared once the old client is gone.
678
+ */
679
+ export const PLATFORM_ARCHON_RETIRED_CLIENT_SCOPE_KEY = 'zitadel_platform_archon_retired_client';
680
+ /** The ZITADEL app id of Archon's sign-in client in the owner company's ZITADEL. */
592
681
  export const PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY = 'zitadel_platform_archon_app';
682
+ /** The ZITADEL project id Archon's sign-in client lives in. */
683
+ export const PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY = 'zitadel_platform_archon_project';
684
+ /** The ZITADEL project and client ids of the Access sign-in provider, in the owner company's ZITADEL. */
685
+ export const PLATFORM_ACCESS_ZITADEL_PROJECT_SCOPE_KEY = 'zitadel_platform_access_project';
686
+ export const PLATFORM_ACCESS_ZITADEL_APP_SCOPE_KEY = 'zitadel_platform_access_app';
593
687
  /**
594
688
  * The ZITADEL app id of Archon's sign-in client once "user info inside the
595
689
  * ID token" was switched on for it. `planArchonIdTokenUserinfo` plans the
@@ -653,7 +747,7 @@ export function appliedAppDomains(org, recorded, entryKey) {
653
747
  * `coolifyApp` records as applied, so a declared change replans and no secret
654
748
  * is written into it.
655
749
  */
656
- export function resolveAppEnv(org, entry, recorded, available = { platformMailKey: false, sharedSecret: false }) {
750
+ export function resolveAppEnv(org, entry, recorded, available = { sharedSecret: false }) {
657
751
  const sources = {
658
752
  filesOrganisationId: recorded[`files:organisation:${org.id}`],
659
753
  aiOrganisationId: recorded[`ai:organisation:${org.id}`],
@@ -665,15 +759,6 @@ export function resolveAppEnv(org, entry, recorded, available = { platformMailKe
665
759
  const unresolvedSecrets = [];
666
760
  const marker = {};
667
761
  for (const [name, declared] of Object.entries(entry.env).sort(([a], [b]) => (a < b ? -1 : 1))) {
668
- if (typeof declared !== 'string' && declared.from === 'platformMailKey') {
669
- if (available.platformMailKey) {
670
- secrets.push({ name, source: 'platformMailKey' });
671
- marker[name] = 'secret:platformMailKey';
672
- }
673
- else
674
- unresolvedSecrets.push([name, 'platformMailKey']);
675
- continue;
676
- }
677
762
  if (typeof declared !== 'string' && declared.from === 'sharedSecret') {
678
763
  const label = `sharedSecret ${declared.name}`;
679
764
  if (available.sharedSecret) {
@@ -708,6 +793,13 @@ export const PLATFORM_AI_KEY = 'platform:ai';
708
793
  * public hostname. Holds whichever way the public-hostname decision goes.
709
794
  */
710
795
  export const PLATFORM_AI_INTERNAL_URL_SCOPE_KEY = 'ai_platform_internal_url';
796
+ /**
797
+ * The id of the tunnel that carries the AI host's public hostname. Recorded once the tunnel, its
798
+ * connector and the DNS record are in place; an entry is given `AI_URL` only after it.
799
+ */
800
+ export const PLATFORM_AI_TUNNEL_SCOPE_KEY = 'cloudflare_platform_ai_tunnel';
801
+ /** The one Coolify app that runs `cloudflared` for the AI tunnel. */
802
+ export const PLATFORM_AI_CONNECTOR_APP_SCOPE_KEY = 'coolify_platform_ai_connector_app';
711
803
  /**
712
804
  * #172: the same two-key discipline as the ZITADEL admin key above, for
713
805
  * foundry's AI management key (the bearer key `ai:organisation`, `ai:budget`
@@ -914,6 +1006,18 @@ export const PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY = 'hc_applied_git_ref';
914
1006
  * and that implementation is where the key is actually used.
915
1007
  */
916
1008
  export const PLATFORM_HC_API_KEY_REF = 'hc:platform:apiKey';
1009
+ /**
1010
+ * foundry#544: the held-key ref a human puts the *outside* Healthchecks
1011
+ * account's project API key under, read only while
1012
+ * `platform.heartbeatsProvider` is `'hosted'` -- {@link PLATFORM_HC_API_KEY_REF}'s
1013
+ * own ref is for the self-hosted instance only, and the two are never
1014
+ * conflated: switching provider needs its own key held under its own ref,
1015
+ * never the other one silently reused. Same "this package never reads the
1016
+ * value back" rule as {@link PLATFORM_HC_API_KEY_REF}'s own doc comment --
1017
+ * whoever implements {@link HeartbeatsPort} against the outside service is
1018
+ * the host.
1019
+ */
1020
+ export const PLATFORM_HEARTBEATS_HOSTED_API_KEY_REF = 'heartbeats:platform:hostedApiKey';
917
1021
  /**
918
1022
  * #540: the heartbeat check named after the uptime Worker (foundry#542,
919
1023
  * which is what actually pings it -- blocked today on edge#26, so nothing
@@ -1113,7 +1217,7 @@ export function uptimeRecorded(recordedIds) {
1113
1217
  * unchanged).
1114
1218
  */
1115
1219
  export function planPlatform(platform, live, options = {}) {
1116
- const { jobsObservable = true, sfuBlocked = null, callsOrgSlugs, recordedIds = {}, blocked = {}, drifted = {}, } = options;
1220
+ const { jobsObservable = true, sfuBlocked = null, callsOrgSlugs, mailRoutes = [], mailRoutesBlocked = [], recordedIds = {}, owner, liveBuilds = {}, blocked = {}, drifted = {}, } = options;
1117
1221
  const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
1118
1222
  const claimed = new Set();
1119
1223
  const plannedSteps = [];
@@ -1148,6 +1252,15 @@ export function planPlatform(platform, live, options = {}) {
1148
1252
  : (service) => `the platform's ${service} is blocked, not planned: every DNS record is written through edge, and edge is neither applied nor planned (${platform.edge.enabled
1149
1253
  ? (edge.blocked ?? 'its step is not planned')
1150
1254
  : 'the platform does not enable edge'})`;
1255
+ // The shared oikos.host ingress: after edge, whose DNS and tunnel calls it uses.
1256
+ const oikosIngress = planOikosIngress(platform, recordedIds, {
1257
+ // Only once edge is APPLIED: the zone check in `blockedSteps` needs edge's address, so a
1258
+ // same-round plan would apply unchecked and throw on a missing zone.
1259
+ edgeApplied,
1260
+ executorBlocked: blocked.oikosIngress,
1261
+ });
1262
+ if (oikosIngress.step)
1263
+ plannedSteps.push(oikosIngress.step);
1151
1264
  // #540: hc needs only edge's DNS, the same as mail and archon -- no
1152
1265
  // auth/ZITADEL client, so it is planned here rather than after `authReady`
1153
1266
  // further down.
@@ -1178,19 +1291,31 @@ export function planPlatform(platform, live, options = {}) {
1178
1291
  const mail = planMail(platform, recordedIds, blocked.mail ?? dnsBlocked?.('mail server'), authReady);
1179
1292
  if (mail.step)
1180
1293
  plannedSteps.push(mail.step);
1294
+ // The shared relay owns host port 25 once `mail.relay` is on, so it runs after the
1295
+ // mail step that gives the port up.
1296
+ const relay = planMailRelay(platform, recordedIds, {
1297
+ routes: mailRoutes,
1298
+ executorBlocked: blocked.relay,
1299
+ });
1300
+ if (relay.step)
1301
+ plannedSteps.push(relay.step);
1181
1302
  // #172: a throw halts every step after it, so this one goes late.
1182
- const ai = planAi(platform, recordedIds, blocked.ai);
1303
+ const ai = planAi(platform, recordedIds, blocked.ai ?? dnsBlocked?.('AI host'));
1183
1304
  if (ai.step)
1184
1305
  plannedSteps.push(ai.step);
1185
1306
  // #604: after edge and sign-in, whose applied ids it reads; before Archon,
1186
1307
  // whose redeploy can end the run.
1187
- const accessProvider = planAccessProvider(platform, recordedIds, blocked.accessProvider);
1308
+ const accessProvider = planAccessProvider(platform, recordedIds, blocked.accessProvider, owner?.signIn ?? null);
1188
1309
  if (accessProvider.step)
1189
1310
  plannedSteps.push(accessProvider.step);
1311
+ // Archon's own sign-in organisation: before Archon, whose client, env and broker entry live in it.
1312
+ const archonOrg = planArchonOrg(platform, recordedIds, blocked.archonOrg, blocked.archonOrgInvite, owner?.signIn ?? null);
1313
+ if (archonOrg.step)
1314
+ plannedSteps.push(archonOrg.step);
1190
1315
  // #175: last, on purpose. Archon's sign-in client lives in the sign-in
1191
1316
  // `planAuth` builds, and its env reads what every other service's step
1192
1317
  // records or delivers, so in one apply it runs after all of them.
1193
- const archon = planArchon(platform, recordedIds, blocked.archon ?? dnsBlocked?.('Archon'), authReady, drifted.archon, blocked.archonDeployRunning);
1318
+ const archon = planArchon(platform, recordedIds, blocked.archon ?? dnsBlocked?.('Archon'), owner?.signIn ?? null, owner?.files ?? null, drifted.archon, blocked.archonDeployRunning, { step: archonOrg.step !== null, blocked: archonOrg.blocked });
1194
1319
  // The host runs inside this app: its step ends by redeploying the process
1195
1320
  // that runs the plan, so it goes after everything else (pushed below).
1196
1321
  if (archon.step && !blocked.archonRunsInsideApp)
@@ -1280,6 +1405,13 @@ export function planPlatform(platform, live, options = {}) {
1280
1405
  unmanaged.push({ description: auth.blocked, key: 'auth' });
1281
1406
  if (mail.blocked)
1282
1407
  unmanaged.push({ description: mail.blocked, key: 'mail' });
1408
+ if (relay.blocked)
1409
+ unmanaged.push({ description: relay.blocked, key: 'mail-relay' });
1410
+ if (platform.mail.relay === true) {
1411
+ for (const description of mailRoutesBlocked) {
1412
+ unmanaged.push({ description, key: 'mail-relay:route' });
1413
+ }
1414
+ }
1283
1415
  if (integrations.blocked)
1284
1416
  unmanaged.push({ description: integrations.blocked, key: 'integrations' });
1285
1417
  if (files.blocked)
@@ -1288,11 +1420,17 @@ export function planPlatform(platform, live, options = {}) {
1288
1420
  unmanaged.push({ description: ai.blocked, key: 'ai' });
1289
1421
  if (edge.blocked)
1290
1422
  unmanaged.push({ description: edge.blocked, key: 'edge' });
1423
+ // Edge's own blocked reason, just above, already names the cause when edge is what is missing.
1424
+ if (oikosIngress.blocked && !edge.blocked) {
1425
+ unmanaged.push({ description: oikosIngress.blocked, key: 'oikos-ingress' });
1426
+ }
1291
1427
  if (accessProvider.blocked) {
1292
1428
  unmanaged.push({ description: accessProvider.blocked, key: 'access-provider' });
1293
1429
  }
1294
1430
  if (hc.blocked)
1295
1431
  unmanaged.push({ description: hc.blocked, key: 'hc' });
1432
+ if (archonOrg.blocked)
1433
+ unmanaged.push({ description: archonOrg.blocked, key: 'archon-org' });
1296
1434
  if (archon.blocked)
1297
1435
  unmanaged.push({ description: archon.blocked, key: 'archon' });
1298
1436
  // #398: the old integrations key stays pinned until Archon holds the new
@@ -1315,7 +1453,7 @@ export function planPlatform(platform, live, options = {}) {
1315
1453
  plannedSteps: plannedSteps.map(withStepSystem),
1316
1454
  unmanaged,
1317
1455
  versionChanges,
1318
- notes: uptime.notes,
1456
+ notes: [...uptime.notes, ...buildDriftNotes(recordedIds, liveBuilds)],
1319
1457
  };
1320
1458
  }
1321
1459
  /**
@@ -1452,19 +1590,13 @@ function planJobs(platform, byKey, claimed, observable, recordedIds) {
1452
1590
  * never a wrong step.
1453
1591
  */
1454
1592
  export const PLATFORM_BACKUP_DATABASE_SCOPE_KEYS = {
1455
- jobs: 'coolify_jobs_database',
1456
- auth: 'coolify_platform_auth_database',
1457
1593
  archon: 'coolify_platform_archon_database',
1458
1594
  ai: 'coolify_platform_ai_database',
1459
- files: 'coolify_platform_files_database',
1460
1595
  integrations: 'coolify_integrations_database',
1461
1596
  };
1462
1597
  export const PLATFORM_BACKUP_SERVICE_KEYS = [
1463
- 'jobs',
1464
- 'auth',
1465
1598
  'archon',
1466
1599
  'ai',
1467
- 'files',
1468
1600
  'integrations',
1469
1601
  ];
1470
1602
  /**
@@ -1922,7 +2054,8 @@ function planMail(platform, recordedIds, executorBlocked, authReady) {
1922
2054
  // #177: a mail step applied before this key existed never recorded it, and
1923
2055
  // org steps read it for Agora's mail upstream. Missing means unapplied.
1924
2056
  recordedIds[PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY] &&
1925
- recordedIds[PLATFORM_MAIL_ROUTE_SCOPE_KEY] === PLATFORM_MAIL_ROUTE_WANTED) {
2057
+ recordedIds[PLATFORM_MAIL_ROUTE_SCOPE_KEY] === PLATFORM_MAIL_ROUTE_WANTED &&
2058
+ mailPortsApplied(platform, recordedIds)) {
1926
2059
  return { step: null, blocked: null };
1927
2060
  }
1928
2061
  // Only the route is missing: say so, the app is not redeployed.
@@ -1930,7 +2063,8 @@ function planMail(platform, recordedIds, executorBlocked, authReady) {
1930
2063
  recordedIds[PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY] &&
1931
2064
  recordedIds[PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY] === platform.mail.hostname &&
1932
2065
  recordedIds[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] === platform.mail.gitRef &&
1933
- recordedIds[PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY];
2066
+ recordedIds[PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY] &&
2067
+ mailPortsApplied(platform, recordedIds);
1934
2068
  // Stalwart's `x:ApiKey/set` shows a secret once. An id was minted but never
1935
2069
  // reached the host: report it, plan nothing, the same rule {@link planAuth}
1936
2070
  // follows for ZITADEL's own admin key.
@@ -2341,16 +2475,16 @@ function planAuthSignInPolicy(platform, recordedIds, wired) {
2341
2475
  * delivered (its secret cannot be shown again), or `executorBlocked` (no
2342
2476
  * dependencies, or edge cannot confirm Access is ready).
2343
2477
  */
2344
- function planAccessProvider(platform, recordedIds, executorBlocked) {
2345
- if (!platform.edge.enabled || !platform.auth.enabled)
2478
+ function planAccessProvider(platform, recordedIds, executorBlocked, ownerSignIn) {
2479
+ if (!platform.edge.enabled)
2346
2480
  return { step: null, blocked: null };
2347
2481
  if (recordedIds[ACCESS_PROVIDER_RECORD_KEY]) {
2348
2482
  // The secret is not stored, so a changed address cannot be re-sent: a person remakes the client.
2349
2483
  const appliedHostname = recordedIds[PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY];
2350
- if (appliedHostname && appliedHostname !== platform.auth.hostname) {
2484
+ if (ownerSignIn && appliedHostname && appliedHostname !== ownerSignIn.hostname) {
2351
2485
  return {
2352
2486
  step: null,
2353
- blocked: `the Access sign-in provider is blocked, not planned: it was applied with the sign-in hostname ${appliedHostname} and ${platform.auth.hostname} is declared now; its secret is not stored, so delete the ZITADEL client cloudflare-access, remove the recorded keys ${ACCESS_PROVIDER_RECORD_KEY}, ${PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY} and ${PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY}, then apply again`,
2487
+ blocked: `the Access sign-in provider is blocked, not planned: it was applied with the sign-in hostname ${appliedHostname} and ${ownerSignIn.hostname} is the owner company's now; its secret is not stored, so delete the ZITADEL client cloudflare-access, remove the recorded keys ${ACCESS_PROVIDER_RECORD_KEY}, ${PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY} and ${PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY}, then apply again`,
2354
2488
  };
2355
2489
  }
2356
2490
  return { step: null, blocked: null };
@@ -2362,15 +2496,10 @@ function planAccessProvider(platform, recordedIds, executorBlocked) {
2362
2496
  blocked: `the Access sign-in provider is blocked, not planned: its ZITADEL client (${undelivered}) was made and its secret may or may not have reached edge; it cannot be shown again, so delete that client in ZITADEL and remove the recorded keys ${PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY} and ${ACCESS_PROVIDER_RECORD_KEY}, then apply again to make both anew`,
2363
2497
  };
2364
2498
  }
2365
- const applied = Boolean(recordedIds[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY]) &&
2366
- Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_AUTH_KEY)]) &&
2367
- // The sibling ZITADEL steps need the delivered admin key (or the instance it came from).
2368
- (Boolean(recordedIds[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY]) ||
2369
- Boolean(recordedIds[PLATFORM_AUTH_INSTANCE_SCOPE_KEY]));
2370
- if (!applied) {
2499
+ if (!ownerSignIn || !recordedIds[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY]) {
2371
2500
  return {
2372
2501
  step: null,
2373
- blocked: 'the Access sign-in provider is blocked, not planned: edge and the platform sign-in are not both applied yet',
2502
+ blocked: "the Access sign-in provider is blocked, not planned: edge and the owner company's sign-in are not both applied yet",
2374
2503
  };
2375
2504
  }
2376
2505
  if (executorBlocked)
@@ -2379,7 +2508,7 @@ function planAccessProvider(platform, recordedIds, executorBlocked) {
2379
2508
  step: {
2380
2509
  action: { system: 'cloudflare', kind: 'access-provider', key: 'platform', desired: {} },
2381
2510
  step: {
2382
- description: "make the platform's ZITADEL the sign-in provider in Cloudflare Access, for company gates",
2511
+ description: "make the owner company's ZITADEL the sign-in provider in Cloudflare Access, for company gates",
2383
2512
  destroys: false,
2384
2513
  migrates: false,
2385
2514
  },
@@ -2469,9 +2598,24 @@ function planHc(platform, recordedIds, executorBlocked) {
2469
2598
  * with. Without this, a fresh platform's heartbeats step is ready
2470
2599
  * (`hcReady`) the same round `hc` is merely *planned*, long before anyone
2471
2600
  * has signed in to it to create that key.
2601
+ *
2602
+ * foundry#544: `platform.heartbeatsProvider === 'hosted'` skips the two
2603
+ * checks above that are specific to the self-hosted server (`platform.hc.enabled`,
2604
+ * `hcReady`) -- the outside service needs neither `hc` declared nor applied.
2605
+ * `apiKeyMissing` still applies in both cases, but names a different ref
2606
+ * depending on the provider (the caller, `platform-providers.ts`'s
2607
+ * `blockedSteps`, resolves the right one before calling this). `desired`
2608
+ * folds the provider in alongside the check names, so switching provider
2609
+ * with the same checks otherwise desired is still a change from the last
2610
+ * recorded marker -- the step plans again, creating every check fresh on
2611
+ * the newly-chosen provider (`provisionPlatformHeartbeats` lists the new
2612
+ * provider's own live checks, sees none of foundry's names yet, and creates
2613
+ * all of them) rather than reading "nothing left to reconcile" against a
2614
+ * marker that was true of the *other* provider.
2472
2615
  */
2473
2616
  function planHeartbeats(platform, recordedIds, hcReady, executorMissing, apiKeyMissing) {
2474
- if (!platform.hc.enabled) {
2617
+ const hosted = platform.heartbeatsProvider === 'hosted';
2618
+ if (!hosted && !platform.hc.enabled) {
2475
2619
  // Removing checks is not planned. The recorded marker names the checks
2476
2620
  // foundry's last reconcile wanted, so they are foundry's own; report
2477
2621
  // them rather than leave them unseen.
@@ -2483,7 +2627,7 @@ function planHeartbeats(platform, recordedIds, hcReady, executorMissing, apiKeyM
2483
2627
  : null,
2484
2628
  };
2485
2629
  }
2486
- if (!hcReady) {
2630
+ if (!hosted && !hcReady) {
2487
2631
  return {
2488
2632
  step: null,
2489
2633
  blocked: 'the heartbeat checks are blocked, not planned: platform:hc has not been applied yet',
@@ -2498,10 +2642,12 @@ function planHeartbeats(platform, recordedIds, hcReady, executorMissing, apiKeyM
2498
2642
  if (apiKeyMissing) {
2499
2643
  return {
2500
2644
  step: null,
2501
- blocked: `the heartbeat checks are blocked, not planned: no project API key is held yet for hc (${PLATFORM_HC_API_KEY_REF}); a human must sign in to hc, create the first superuser account and a project, mint that project's API key, and hold it under that ref (issue #540 "Human steps") before this step can call hc's check-creation API`,
2645
+ blocked: hosted
2646
+ ? `the heartbeat checks are blocked, not planned: no project API key is held yet for the outside Healthchecks account (${PLATFORM_HEARTBEATS_HOSTED_API_KEY_REF}); a human must create that account (or project), mint its API key, and hold it under that ref (issue #544 "Human steps") before this step can call its check-creation API`
2647
+ : `the heartbeat checks are blocked, not planned: no project API key is held yet for hc (${PLATFORM_HC_API_KEY_REF}); a human must sign in to hc, create the first superuser account and a project, mint that project's API key, and hold it under that ref (issue #540 "Human steps") before this step can call hc's check-creation API`,
2502
2648
  };
2503
2649
  }
2504
- const desired = [...desiredHeartbeatCheckNames(recordedIds)].sort().join(',');
2650
+ const desired = `${platform.heartbeatsProvider}:${[...desiredHeartbeatCheckNames(recordedIds)].sort().join(',')}`;
2505
2651
  if (recordedIds[PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY] === desired) {
2506
2652
  return { step: null, blocked: null };
2507
2653
  }
@@ -2656,7 +2802,9 @@ function planAi(platform, recordedIds, executorBlocked) {
2656
2802
  // already set then, so nothing is minted again.
2657
2803
  const applied = Boolean(recordedIds[key]);
2658
2804
  const drifted = recordedIds[PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY] !== platform.ai.hostname ||
2659
- recordedIds[PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY] !== platform.ai.gitRef;
2805
+ recordedIds[PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY] !== platform.ai.gitRef ||
2806
+ // The public route (tunnel, connector, DNS record) is part of the step: a host applied before it existed plans it once.
2807
+ !recordedIds[PLATFORM_AI_TUNNEL_SCOPE_KEY];
2660
2808
  if (applied && !drifted)
2661
2809
  return { step: null, blocked: null };
2662
2810
  // #201: the signing pair foundry mints to authenticate its own calls to
@@ -2746,13 +2894,67 @@ function planAi(platform, recordedIds, executorBlocked) {
2746
2894
  * ({@link PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY}) plans the step again, as
2747
2895
  * {@link planAuth} does.
2748
2896
  */
2749
- function planArchon(platform, recordedIds, executorBlocked, authReady, drifted, deployRunning) {
2897
+ /** Archon signs its operators in through the owner company's own sign-in; fixed text, part of the stale-plan comparison. */
2898
+ export const ARCHON_OWNER_SIGN_IN_BLOCKED = "Archon is blocked, not planned: the owner company's sign-in is not applied yet";
2899
+ /** Archon signs in from its own organisation, which the `zitadel:archon-org` step makes; fixed text, part of the stale-plan comparison. */
2900
+ export const ARCHON_ORG_BLOCKED = 'Archon is blocked, not planned: its own sign-in organisation is not applied yet';
2901
+ /**
2902
+ * Archon's own sign-in organisation in the owner company's ZITADEL: the org,
2903
+ * its primary domain, closed registration, the second-factor policy, and the
2904
+ * first person's invite. One `zitadel:archon-org` step keyed
2905
+ * {@link PLATFORM_ARCHON_KEY}, planned while any of those is not recorded
2906
+ * against the recorded org, and always before `platform:archon`. Blocked, not
2907
+ * planned, while the host cannot reach the instance. A refused domain is
2908
+ * reported as a standing note (as the owner org's is) and does not hold the
2909
+ * step; an invite that cannot be sent (no work address, no mail port) is a
2910
+ * standing note and does not hold it either.
2911
+ */
2912
+ function planArchonOrg(platform, recordedIds, executorBlocked, inviteBlocked, ownerSignIn) {
2913
+ if (!platform.archon.enabled || !ownerSignIn)
2914
+ return { step: null, blocked: null };
2915
+ const orgId = recordedIds[PLATFORM_ARCHON_ORG_SCOPE_KEY];
2916
+ const blockedDomain = recordedIds[PLATFORM_ARCHON_ORG_DOMAIN_BLOCKED_SCOPE_KEY];
2917
+ const domainDone = Boolean(recordedIds[PLATFORM_ARCHON_ORG_DOMAIN_SCOPE_KEY]) || Boolean(blockedDomain);
2918
+ const inviteRecorded = recordedIds[PLATFORM_ARCHON_OWNER_INVITE_SCOPE_KEY];
2919
+ const inviteDone = Boolean(inviteBlocked) ||
2920
+ inviteRecorded === 'none' ||
2921
+ (Boolean(inviteRecorded) && recordedIds[PLATFORM_ARCHON_OWNER_INVITE_ORG_SCOPE_KEY] === orgId);
2922
+ const standing = [
2923
+ inviteBlocked,
2924
+ blockedDomain && !recordedIds[PLATFORM_ARCHON_ORG_DOMAIN_SCOPE_KEY]
2925
+ ? `Archon's sign-in domain ${blockedDomain} is blocked, not planned: the sign-in server refused to make it primary because its domain policy wants proof of ownership. Set the policy ValidateOrgDomains to false on the sign-in server, then remove the recorded key ${PLATFORM_ARCHON_ORG_DOMAIN_BLOCKED_SCOPE_KEY}`
2926
+ : null,
2927
+ ].filter((v) => Boolean(v));
2928
+ const standingNote = standing.length > 0 ? standing.join('; ') : null;
2929
+ if (orgId &&
2930
+ domainDone &&
2931
+ recordedIds[PLATFORM_ARCHON_ORG_REGISTRATION_CLOSED_SCOPE_KEY] === orgId &&
2932
+ recordedIds[PLATFORM_ARCHON_ORG_POLICY_SCOPE_KEY] === orgId &&
2933
+ inviteDone) {
2934
+ return { step: null, blocked: standingNote };
2935
+ }
2936
+ if (executorBlocked)
2937
+ return { step: null, blocked: executorBlocked };
2938
+ return {
2939
+ step: {
2940
+ action: { system: 'zitadel', kind: 'archon-org', key: PLATFORM_ARCHON_KEY, desired: {} },
2941
+ step: {
2942
+ description: "make Archon's own sign-in organisation in the owner company's ZITADEL: close registration, force a second factor, and invite the first person",
2943
+ destroys: false,
2944
+ migrates: false,
2945
+ },
2946
+ },
2947
+ blocked: standingNote,
2948
+ };
2949
+ }
2950
+ function planArchon(platform, recordedIds, executorBlocked, ownerSignIn, ownerFiles, drifted, deployRunning, archonOrg = { step: false, blocked: null }) {
2750
2951
  if (!platform.archon.enabled)
2751
2952
  return { step: null, blocked: null };
2752
2953
  // A deploy the step triggered is still running: planning it again would deploy twice.
2753
2954
  if (deployRunning)
2754
2955
  return { step: null, blocked: deployRunning };
2755
2956
  const key = resourceKey('coolify', 'app', PLATFORM_ARCHON_KEY);
2957
+ const archonOrgId = recordedIds[PLATFORM_ARCHON_ORG_SCOPE_KEY];
2756
2958
  const applied = Boolean(recordedIds[key]) &&
2757
2959
  Boolean(recordedIds[PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY]) &&
2758
2960
  recordedIds[PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY] === platform.archon.hostname &&
@@ -2761,11 +2963,20 @@ function planArchon(platform, recordedIds, executorBlocked, authReady, drifted,
2761
2963
  // deleted by hand, or `platform:auth` minting a fresh one) leaves
2762
2964
  // Archon's last registration on a broker that no longer exists, and
2763
2965
  // nothing else here would ever plan the step again to redo it.
2764
- Boolean(recordedIds[PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY]) &&
2765
- recordedIds[PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY] ===
2766
- recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY] &&
2767
- recordedIds[PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY] ===
2768
- archonAuthOrganizationId(recordedIds) &&
2966
+ ownerSignIn !== null &&
2967
+ recordedIds[PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY] === ownerSignIn.brokerAppId &&
2968
+ // Archon signs in from its own organisation, never the company's.
2969
+ Boolean(archonOrgId) &&
2970
+ recordedIds[PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY] === archonOrgId &&
2971
+ recordedIds[PLATFORM_ARCHON_APPLIED_CI_ORG_SCOPE_KEY] === ownerSignIn.organisationId &&
2972
+ (recordedIds[PLATFORM_ARCHON_APPLIED_FIRST_OWNER_LINK_SCOPE_KEY] ?? '') ===
2973
+ archonFirstOwnerLink(recordedIds, ownerSignIn) &&
2974
+ recordedIds[PLATFORM_ARCHON_APP_KEY_ORG_SCOPE_KEY] === archonOrgId &&
2975
+ !recordedIds[PLATFORM_ARCHON_RETIRED_CLIENT_SCOPE_KEY] &&
2976
+ Boolean(recordedIds[PLATFORM_ARCHON_ACCESS_PIN_SCOPE_KEY]) &&
2977
+ recordedIds[PLATFORM_ARCHON_APPLIED_AUTH_HOSTNAME_SCOPE_KEY] === ownerSignIn.hostname &&
2978
+ (recordedIds[PLATFORM_ARCHON_APPLIED_FILES_SCOPE_KEY] ?? 'none') ===
2979
+ archonFilesMarker(ownerFiles) &&
2769
2980
  // #475: a platform applied before the rolling-update fix keeps planning
2770
2981
  // this step until a confirmed redeploy has cleared it. #480: this marker
2771
2982
  // alone does not catch the live app's settings changing back afterward
@@ -2797,17 +3008,11 @@ function planArchon(platform, recordedIds, executorBlocked, authReady, drifted,
2797
3008
  recordedIds[PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY]);
2798
3009
  if (applied && !drifted)
2799
3010
  return { step: null, blocked: null };
2800
- if (!platform.auth.enabled) {
2801
- return {
2802
- step: null,
2803
- blocked: "Archon is blocked, not planned: its sign-in client lives in the platform's own sign-in, and auth is not enabled",
2804
- };
2805
- }
2806
- if (!authReady) {
2807
- return {
2808
- step: null,
2809
- blocked: "Archon is blocked, not planned: its sign-in client lives in the platform's own sign-in, which is neither applied nor planned",
2810
- };
3011
+ if (!ownerSignIn)
3012
+ return { step: null, blocked: ARCHON_OWNER_SIGN_IN_BLOCKED };
3013
+ // The org is recorded, or its step runs earlier in this same plan.
3014
+ if (!archonOrgId && !archonOrg.step) {
3015
+ return { step: null, blocked: archonOrg.blocked ?? ARCHON_ORG_BLOCKED };
2811
3016
  }
2812
3017
  if (executorBlocked)
2813
3018
  return { step: null, blocked: executorBlocked };