@wtfalch/foundry 0.17.0 → 0.17.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.
package/dist/plan/diff.js CHANGED
@@ -3,12 +3,14 @@ import { APP_ENTRY_KEY, ownerMailboxRecorded, ownerMailboxes } from '../org.js';
3
3
  import { AGORA_ACTIVATED_ENV_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
4
4
  import { BOULE_BROKER_ENTRY_KEY, bouleEnvDrift, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
5
5
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
6
+ import { planEdgeWorkers } from './edge-worker.js';
6
7
  import { planEntryAiKeys } from './entry-ai-key.js';
7
8
  import { planFilesKeys } from './entry-files-key.js';
9
+ import { planEntryNamesKeys, planNamesReservedLabels } from './entry-names-key.js';
8
10
  import { appHostname, entryHostnames, tunnelDnsHostnames } from './hostnames.js';
9
11
  import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, loginHostnameFor, pendingRemovalHosts, } from './login-host.js';
10
12
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
11
- import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, appEnvAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, versionNotAppliedLine, } from './platform-diff.js';
13
+ import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, appEnvAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, versionNotAppliedLine, } from './platform-diff.js';
12
14
  import { DEFAULT_ORG_PLATFORM_READINESS } from './platform-org.js';
13
15
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
14
16
  import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec } from './proxy-labels.js';
@@ -87,7 +89,12 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
87
89
  // #267: after the ZITADEL org step, before any app's own issuer
88
90
  // application. Apps created later in this run are not in the binding yet;
89
91
  // the next run replans this step and repoints them.
90
- const loginHost = planLoginHost(org, recordedIds, platformReadiness.authReady, issuer.step !== null, options.loginHostBindings ?? false);
92
+ const loginHost = planLoginHost(org, recordedIds, platformReadiness.authReady, issuer.step !== null, options.loginHostBindings ?? false,
93
+ // #488: `agoraHost.issuer` is the shared ZITADEL instance's own issuer
94
+ // URL (`https://<platform.auth.hostname>`), the same value every org's
95
+ // sign-in already reads through `planOwners` above -- no separate port
96
+ // or plumbing needed to know the platform's own auth hostname.
97
+ platformAuthHostnameFrom('error' in agoraHost ? null : agoraHost.issuer));
91
98
  if (loginHost.step)
92
99
  plannedSteps.push(loginHost.step);
93
100
  // Planned ahead of everything that needs the zone live (#238); its own step
@@ -95,7 +102,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
95
102
  const zone = planZone(org, byKey, claimed);
96
103
  const zoneWaiting = zone.blocked !== null;
97
104
  const zoneBlocked = [];
98
- const mail = planMail(org, byKey, claimed, recordedIds, platformReadiness.mailHost);
105
+ const mail = planMail(org, byKey, claimed, recordedIds, platformReadiness.mailHost, platformReadiness.platformApex, platformReadiness.platformLettermintProjectId);
99
106
  // #238: own-domain mail publishes DNS records into the zone, so it waits for
100
107
  // an active zone -- blocked, not planned (rule 2).
101
108
  if (mail.step && zoneWaiting && org.mail.usesDomain && org.domain) {
@@ -197,6 +204,16 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
197
204
  // F6b: after the owner, the apps and the files organisation it needs.
198
205
  const filesKeys = planFilesKeys(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)());
199
206
  plannedSteps.push(...filesKeys.steps);
207
+ // F7 (#317): reserved labels before any entry's own key, so a label is
208
+ // never claimable by a customer in the gap between an entry's key going
209
+ // live and its siblings' labels reaching names.
210
+ const namesReservedLabels = planNamesReservedLabels(org, recordedIds, platformReadiness);
211
+ if (namesReservedLabels.step)
212
+ plannedSteps.push(namesReservedLabels.step);
213
+ // After the apps -- a names key needs the entry's app recorded, the same
214
+ // dependency `planEntryAiKeys`/`planFilesKeys` have.
215
+ const entryNamesKeys = planEntryNamesKeys(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)());
216
+ plannedSteps.push(...entryNamesKeys.steps);
200
217
  const tunnel = planTunnel(org, byKey, claimed, recordedIds);
201
218
  // #238: a tunnel that is being provisioned waits for an active zone; parking
202
219
  // one never does.
@@ -254,6 +271,26 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
254
271
  const jobsRegistration = planJobsRegistration(org, recordedIds, jobsAvailable || platformReadiness.jobsReady, platformReadiness.missingKeys.jobs, platformReadiness.noKeyReader);
255
272
  if (jobsRegistration.step)
256
273
  plannedSteps.push(jobsRegistration.step);
274
+ // F8 (#318), after Agora and Boule, not right after the zone: a route
275
+ // already bound by a hand-made entry, another entry's route, a taken
276
+ // script name, or no Workers token all throw at apply, and `runSteps`
277
+ // stops at the first failure -- planned this early, a failing edge step
278
+ // stopped Agora (and everything after it) from provisioning at all
279
+ // (#318 re-review). `cloudflareWorker` still resolves the zone by apex
280
+ // domain the same way `planRateLimit` does, so a deploy (never a
281
+ // retire, which needs no new zone) still waits for it.
282
+ const edgeWorkers = planEdgeWorkers(org, recordedIds, options.edgeWorkers ?? false);
283
+ for (const step of edgeWorkers.steps) {
284
+ if (zoneWaiting && step.action.desired.mode === 'apply') {
285
+ zoneBlocked.push({
286
+ description: `${org.slug}'s ${step.action.key.slice(org.id.length + 1)} edge Worker is blocked, not planned: the zone for ${org.domain} is not active yet`,
287
+ key: step.action.key,
288
+ });
289
+ }
290
+ else {
291
+ plannedSteps.push(step);
292
+ }
293
+ }
257
294
  // #10 (resolved): "foundry manages only what the declared org names";
258
295
  // anything left unclaimed is listed, never proposed for deletion.
259
296
  const unmanaged = live
@@ -274,6 +311,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
274
311
  unmanaged.push({ description: aiKey.blocked, key: 'ai:key' });
275
312
  unmanaged.push(...filesKeys.blocked);
276
313
  unmanaged.push(...entryAiKeys.blocked);
314
+ unmanaged.push(...edgeWorkers.blocked);
315
+ unmanaged.push(...entryNamesKeys.blocked);
316
+ unmanaged.push(...namesReservedLabels.blocked);
277
317
  // Same for a jobs registration that cannot run (no scheduler wired) or must
278
318
  // not run again (minted, never delivered).
279
319
  if (jobsRegistration.blocked) {
@@ -471,6 +511,24 @@ function planOwners(org, recordedIds, authReady, authIssuer) {
471
511
  });
472
512
  return steps;
473
513
  }
514
+ /**
515
+ * #488: the bare hostname out of an issuer URL (`https://<hostname>`, the
516
+ * only shape {@link agoraHostValuesFromPlatform} or a host-supplied
517
+ * `AgoraHostPort` ever hands back), or `null` when there is no issuer to
518
+ * read one from. What {@link loginHostnameFor} compares the org's own
519
+ * computed `auth.<domain>` against, so the platform's own org never plans
520
+ * its login host equal to the shared ZITADEL instance's own.
521
+ */
522
+ function platformAuthHostnameFrom(issuer) {
523
+ if (!issuer)
524
+ return null;
525
+ try {
526
+ return new URL(issuer).hostname;
527
+ }
528
+ catch {
529
+ return null;
530
+ }
531
+ }
474
532
  /**
475
533
  * #267: the org's own company login host (`login-host.ts`'s
476
534
  * `loginHostnameFor`) -- `auth.<domain>` once the org has one,
@@ -501,11 +559,22 @@ function planOwners(org, recordedIds, authReady, authIssuer) {
501
559
  * runs well before `planIssuerApps`/`planCmsClient`/`planAgoraClient`
502
560
  * create any app under the org's project -- #267's own "before any app's
503
561
  * issuer application".
562
+ *
563
+ * `platformAuthHostname` (#488) is threaded through to {@link
564
+ * loginHostnameFor} so the platform's own org, declared with-domain on the
565
+ * platform's own domain, never plans the shared ZITADEL instance's own login
566
+ * host as its own -- `auth.companyincluded.com` colliding with itself.
504
567
  */
505
- function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired) {
568
+ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired, platformAuthHostname) {
506
569
  const none = { step: null, blocked: null };
507
570
  const key = resourceKey('zitadel', 'login-host', org.id);
508
- const host = loginHostnameFor(org);
571
+ const host = loginHostnameFor(org, platformAuthHostname);
572
+ // #488: the guard in `loginHostnameFor` fired -- the org's own domain host
573
+ // would have been the platform's shared ZITADEL instance host.
574
+ const sharedInstanceHost = Boolean(org.domain) &&
575
+ org.issuer.usesDomain &&
576
+ `auth.${org.domain}` === platformAuthHostname &&
577
+ host !== `auth.${org.domain}`;
509
578
  // Already applied -- a re-run plans nothing unless the host changed, an app
510
579
  // client id appeared since the binding was last written (#267, rule 4), or
511
580
  // an earlier host change still has a removal owed (#274 fix round 2):
@@ -549,7 +618,9 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
549
618
  desired: { host },
550
619
  },
551
620
  step: {
552
- description: `set ${org.slug}'s login host to ${host}`,
621
+ description: sharedInstanceHost
622
+ ? `set ${org.slug}'s login host to ${host}: its domain host (auth.${org.domain}) is the platform's own shared ZITADEL instance host, so it stays on oikos.host`
623
+ : `set ${org.slug}'s login host to ${host}`,
553
624
  destroys: false,
554
625
  migrates: false,
555
626
  },
@@ -577,9 +648,14 @@ export const JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY = 'jobs_registration_undeli
577
648
  * off this step's own `desired`, the same "the executor writes exactly these
578
649
  * host values, so what the plan showed is what gets applied" rule
579
650
  * `planAgora` already follows for its own host values -- never rebuilding
580
- * them itself from a literal or a second lookup.
651
+ * them itself from a literal or a second lookup. #491: `platformApex` and
652
+ * `platformLettermintProjectId` go into `desired` the same way, so the
653
+ * executor can recognise the platform's own org without needing the whole
654
+ * `DeclaredPlatform`.
581
655
  */
582
- function planMail(org, byKey, claimed, recordedIds, mailHost) {
656
+ function planMail(org, byKey, claimed, recordedIds, mailHost,
657
+ /** #491 (review round 2): read off `platformReadiness` directly, not off `mailHost` -- see `OrgPlatformReadiness.platformApex`'s own doc comment for why. */
658
+ platformApex, platformLettermintProjectId) {
583
659
  const key = resourceKey('coolify', 'mail', org.id);
584
660
  const live = byKey.get(key);
585
661
  // `off` means mail is not foundry's concern here (org.ts's MailMode doc);
@@ -599,11 +675,32 @@ function planMail(org, byKey, claimed, recordedIds, mailHost) {
599
675
  mailboxBlocked: null,
600
676
  };
601
677
  }
678
+ // #491: this org's own declared domain is the platform's apex (Company
679
+ // Included on companyincluded.com, today) -- it shares the platform's
680
+ // existing Lettermint project rather than minting a `<slug>-app` one of
681
+ // its own, and `executor.ts` must never touch Lettermint for it until an
682
+ // operator has said which project that is. Blocked whole, not planned
683
+ // half-ready: "Blocked, not failing" (foundry's own CLAUDE.md).
684
+ const resolvedMailDomain = org.mail.usesDomain ? org.domain : null;
685
+ const isPlatformApexOrg = Boolean(resolvedMailDomain &&
686
+ platformApex &&
687
+ resolvedMailDomain.toLowerCase() === platformApex.toLowerCase());
688
+ if (isPlatformApexOrg && !platformLettermintProjectId) {
689
+ return {
690
+ step: null,
691
+ blocked: `${org.slug}'s mail is blocked, not planned: its domain ${resolvedMailDomain} is the platform's own apex, and platform.mail.lettermintProjectId is not declared yet (#491)`,
692
+ mailboxBlocked: null,
693
+ };
694
+ }
602
695
  const desired = {
603
696
  mode: org.mail.mode,
604
697
  usesDomain: org.mail.usesDomain,
605
698
  apex: mailHost.apex,
606
699
  mailHost: mailHost.mailHost,
700
+ // #491: non-null whenever `mailHost` is -- both require a declared
701
+ // platform (`orgPlatformReadinessFrom`'s own doc comment).
702
+ platformApex,
703
+ platformLettermintProjectId,
607
704
  };
608
705
  // #162 (decided 2026-09-29): an owner's mailbox is minted inside the same
609
706
  // `coolify:mail` step, after `applyOrgMail` -- so as long as one is still
@@ -799,8 +896,35 @@ function planChat(org, byKey, claimed, recordedIds, reading, issuerPlanned) {
799
896
  };
800
897
  }
801
898
  const liveUsesDomain = Boolean(live.config.usesDomain);
802
- if (liveUsesDomain === desiredUsesDomain)
899
+ if (liveUsesDomain === desiredUsesDomain) {
900
+ // #456: a live chat app made before the owner/runtime database split
901
+ // (#239) otherwise never gets another `coolify:chat` step at all --
902
+ // nothing else here ever differs for it again -- and its service keeps
903
+ // running on the bare superuser `DATABASE_URL` its own startup script
904
+ // now refuses. `coolifyChat` throws on any other action against an
905
+ // already-live chat (re-keying is not built, #7 amendment 3), so this
906
+ // step carries a distinct flag telling the executor to push only the
907
+ // split, never to replay the full provision.
908
+ if (!recordedIds[serviceDbEnvAppliedScopeKey('chat')]) {
909
+ return {
910
+ step: {
911
+ action: {
912
+ system: 'coolify',
913
+ kind: 'chat',
914
+ key: org.id,
915
+ desired: { usesDomain: liveUsesDomain, refreshDatabaseSplit: true },
916
+ },
917
+ step: {
918
+ description: `give ${org.slug}'s chat service its owner and runtime database URLs and redeploy it (#239, #456)`,
919
+ destroys: false,
920
+ migrates: false,
921
+ },
922
+ },
923
+ blocked: null,
924
+ };
925
+ }
803
926
  return none;
927
+ }
804
928
  const domain = org.domain ?? 'its domain';
805
929
  if (!liveUsesDomain && desiredUsesDomain) {
806
930
  return {
@@ -933,17 +1057,19 @@ function planCallsService(org, byKey, claimed, callsReady, recordedIds, callsBlo
933
1057
  : step;
934
1058
  if (toggle)
935
1059
  return { step: withHost(toggle), blocked: null };
936
- // A live, enabled app made before the internal name or the token env
937
- // existed has no step from the toggle. Run the step again until the
938
- // executor has recorded both markers, but only while it can succeed: the
939
- // SFU ready and the Matrix stack not blocked.
1060
+ // A live, enabled app made before the internal name, the token env or the
1061
+ // owner/runtime database split (#239, #456) existed has no step from the
1062
+ // toggle. Run the step again until the executor has recorded every marker,
1063
+ // but only while it can succeed: the SFU ready and the Matrix stack not
1064
+ // blocked.
940
1065
  const missingName = !callsServiceHasName(org, recordedIds);
941
1066
  const missingTokenEnv = !callsServiceHasTokenEnv(tokenHost, recordedIds);
1067
+ const missingDbSplit = !recordedIds[serviceDbEnvAppliedScopeKey('calls-service')];
942
1068
  if (org.calls.enabled &&
943
1069
  callsReady &&
944
1070
  !callsBlocked &&
945
1071
  byKey.has(key) &&
946
- (missingName || missingTokenEnv)) {
1072
+ (missingName || missingTokenEnv || missingDbSplit)) {
947
1073
  return {
948
1074
  step: withHost({
949
1075
  action: {
@@ -955,7 +1081,9 @@ function planCallsService(org, byKey, claimed, callsReady, recordedIds, callsBlo
955
1081
  step: {
956
1082
  description: missingName
957
1083
  ? `set the calls service's internal name for ${org.slug}`
958
- : `set the calls service's ZITADEL token env for ${org.slug}`,
1084
+ : missingTokenEnv
1085
+ ? `set the calls service's ZITADEL token env for ${org.slug}`
1086
+ : `give the calls service's owner and runtime database URLs for ${org.slug} and redeploy it (#239, #456)`,
959
1087
  destroys: false,
960
1088
  migrates: false,
961
1089
  },
@@ -976,6 +1104,32 @@ function planCallsService(org, byKey, claimed, callsReady, recordedIds, callsBlo
976
1104
  * explicit opt-in this default deliberately leaves out.
977
1105
  */
978
1106
  const AI_DEFAULT_OFFERS = ['ai.offerings:invoke', 'ai.runs:create'];
1107
+ /**
1108
+ * #484: the spend cap foundry applies when an org declares no AI limit
1109
+ * (`org.ai.limitMicros === null`) -- Archon creates every company that way,
1110
+ * and AI's own `updateBudget` has no uncapped representation
1111
+ * (`UpdateBudget.limitMicros` is a required string), so a null limit used to
1112
+ * throw `ai:budget` out of every apply. William's Decision, 2026-10-05: 100
1113
+ * EUR, in `ai`'s own USD-micros unit (`usdToMicros`,
1114
+ * `ai/packages/service/src/money.ts`), billing to revisit the number later.
1115
+ * 100 EUR at the ECB reference rate for 2 October 2026 (1.1225 USD/EUR, the
1116
+ * most recent published rate as of the decision) is 112.25 USD = 112,250,000
1117
+ * micros. A declared numeric limit always wins over this default --
1118
+ * {@link effectiveAiLimitMicros} is the one place that substitutes it.
1119
+ */
1120
+ export const DEFAULT_AI_LIMIT_MICROS = '112250000';
1121
+ /**
1122
+ * #484: the limit foundry actually plans and applies for an org -- its own
1123
+ * declared `ai.limitMicros` when it declares one, else {@link DEFAULT_AI_LIMIT_MICROS}.
1124
+ * Both {@link planAiOrganisation} and {@link planAiBudget} fill
1125
+ * `action.desired.limitMicros` through this one function, so a company with
1126
+ * no declared limit gets the same 100 EUR cap whichever step sets it, and
1127
+ * never reaches `executor.ts`'s `aiBudget` with a null `desired.limitMicros`
1128
+ * for `updateBudget` to choke on.
1129
+ */
1130
+ function effectiveAiLimitMicros(limitMicros) {
1131
+ return limitMicros ?? DEFAULT_AI_LIMIT_MICROS;
1132
+ }
979
1133
  /**
980
1134
  * Why an org step on a shared platform service is blocked: a key that is not
981
1135
  * held, no held-keys reader given at all, or the service off or not applied.
@@ -1073,9 +1227,14 @@ function planFilesOrganisation(org, byKey, claimed, filesReady, missingKeyRef, n
1073
1227
  * regardless), so the only planned state is "not provisioned yet".
1074
1228
  * `AI_DEFAULT_OFFERS` is this campaign's Decision for what "AI use is
1075
1229
  * switched on" means by default; `limitMicros` seeds the organisation's
1076
- * first budget row with whatever the org already declares (including
1077
- * `null` for uncapped), so a brand new org never needs a follow-up
1078
- * {@link planAiBudget} step just to reach its own declared default.
1230
+ * first budget row with the org's own declared limit, or
1231
+ * {@link DEFAULT_AI_LIMIT_MICROS} when it declares none (#484) --
1232
+ * {@link effectiveAiLimitMicros} either way, the same value
1233
+ * {@link planAiBudget} independently plans from `live` -- so a brand new
1234
+ * org's first plan always carries both steps (same shape as any other
1235
+ * declared limit, `ai:organisation` before `ai:budget`), and once that one
1236
+ * apply run has pushed both, a later plan finds no further drift to
1237
+ * replan.
1079
1238
  *
1080
1239
  * #177: blocked, not planned, while `aiReady` is false -- the platform's own
1081
1240
  * AI host (#172) is off or not yet applied.
@@ -1101,12 +1260,14 @@ function planAiOrganisation(org, byKey, claimed, aiReady, missingKeyRef, noKeyRe
1101
1260
  key: org.id,
1102
1261
  desired: {
1103
1262
  provision: true,
1104
- limitMicros: org.ai.limitMicros,
1263
+ limitMicros: effectiveAiLimitMicros(org.ai.limitMicros),
1105
1264
  offers: [...AI_DEFAULT_OFFERS],
1106
1265
  },
1107
1266
  },
1108
1267
  step: {
1109
- description: `provision ${org.slug}'s own organisation on AI and switch AI use on`,
1268
+ description: org.ai.limitMicros === null
1269
+ ? `provision ${org.slug}'s own organisation on AI and switch AI use on, capped at the default of ${DEFAULT_AI_LIMIT_MICROS} micros (no limit declared)`
1270
+ : `provision ${org.slug}'s own organisation on AI and switch AI use on`,
1110
1271
  destroys: false,
1111
1272
  migrates: false,
1112
1273
  },
@@ -1118,8 +1279,16 @@ function planAiOrganisation(org, byKey, claimed, aiReady, missingKeyRef, noKeyRe
1118
1279
  * #27: the org's own spend limit on AI, a per-org singleton resource in the
1119
1280
  * same singleton shape as issuer/mail/chat above. Planned after {@link planAiOrganisation}
1120
1281
  * (#80), which already seeds a brand new org's first budget row from this
1121
- * same `org.ai.limitMicros` -- this planner only fires again once that row's
1122
- * live value has drifted from what is declared.
1282
+ * same `org.ai.limitMicros` (or {@link DEFAULT_AI_LIMIT_MICROS}, #484) --
1283
+ * this planner only fires again once that row's live value has drifted from
1284
+ * what is declared (or defaulted).
1285
+ *
1286
+ * #484: `desired.limitMicros` goes through {@link effectiveAiLimitMicros},
1287
+ * never the bare `org.ai.limitMicros` -- `executor.ts`'s `aiBudget` calls
1288
+ * AI's `updateBudget`, whose `limitMicros` is a required string with no
1289
+ * uncapped representation, so a step that reached it with `null` always
1290
+ * threw. The description names the default explicitly when it is the one in
1291
+ * effect, so an operator reading the plan sees which cap applies.
1123
1292
  *
1124
1293
  * #177: silently plans nothing while there is no organisation to size a
1125
1294
  * budget on yet and `aiReady` is false -- {@link planAiOrganisation} already
@@ -1132,7 +1301,8 @@ function planAiBudget(org, byKey, claimed, aiReady, missingKeyRef, noKeyReader =
1132
1301
  claimed.add(key);
1133
1302
  if (!aiReady && !live)
1134
1303
  return { step: null, blocked: null };
1135
- const desired = { limitMicros: org.ai.limitMicros };
1304
+ const isDefault = org.ai.limitMicros === null;
1305
+ const desired = { limitMicros: effectiveAiLimitMicros(org.ai.limitMicros) };
1136
1306
  const liveLimitMicros = live ? (live.config.limitMicros ?? null) : null;
1137
1307
  if (liveLimitMicros === desired.limitMicros)
1138
1308
  return { step: null, blocked: null };
@@ -1147,7 +1317,9 @@ function planAiBudget(org, byKey, claimed, aiReady, missingKeyRef, noKeyReader =
1147
1317
  step: {
1148
1318
  action: { system: 'ai', kind: 'budget', key: org.id, desired },
1149
1319
  step: {
1150
- description: `set ${org.slug}'s AI budget limit to ${desired.limitMicros ?? 'uncapped'} micros`,
1320
+ description: isDefault
1321
+ ? `set ${org.slug}'s AI budget limit to the default of ${desired.limitMicros} micros (no limit declared)`
1322
+ : `set ${org.slug}'s AI budget limit to ${desired.limitMicros} micros`,
1151
1323
  destroys: false,
1152
1324
  migrates: false,
1153
1325
  },
@@ -1657,6 +1829,32 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
1657
1829
  key: blockedKey(`env:${name}`),
1658
1830
  })))
1659
1831
  : [];
1832
+ // #491: the `app` entry's own Lettermint work (`executor.ts`'s
1833
+ // `ensureAppLettermintMail`) reads `MAIL_DOMAIN_SCOPE_KEY` directly, which
1834
+ // can already be recorded from before this org's domain became the
1835
+ // platform's apex -- so this is checked here too, independently of
1836
+ // whether `coolify:mail` itself is blocked this run (`planMail` above).
1837
+ // Reported, not fatal: the rest of this entry's own plan is unaffected.
1838
+ // Review round 2: reads `readiness.platformApex` directly, not
1839
+ // `readiness.mailHost?.platformApex` -- the platform's own apex is a fact
1840
+ // independent of whether its mail server happens to be enabled or applied
1841
+ // right now, and gating this on `mailHost` let a platform-apex org with an
1842
+ // already-recorded domain take the unprotected path the moment platform
1843
+ // mail went back to off.
1844
+ if (entryKey === APP_ENTRY_KEY) {
1845
+ const mailDomain = recordedIds[MAIL_DOMAIN_SCOPE_KEY];
1846
+ const platformApex = readiness.platformApex;
1847
+ const platformLettermintProjectId = readiness.platformLettermintProjectId;
1848
+ if (mailDomain &&
1849
+ platformApex &&
1850
+ mailDomain.toLowerCase() === platformApex.toLowerCase() &&
1851
+ !platformLettermintProjectId) {
1852
+ blocked.push({
1853
+ description: `${org.slug}'s app Lettermint mail is blocked, not planned: its mail domain is the platform's own apex, and platform.mail.lettermintProjectId is not declared yet (#491)`,
1854
+ key: blockedKey('lettermint-project'),
1855
+ });
1856
+ }
1857
+ }
1660
1858
  const toggled = planHostingToggle('coolify', 'app', entry !== undefined, org, byKey, claimed, () => `provision ${org.slug}'s ${label}`, () => `park ${org.slug}'s ${label} (stops serving; app data is retained, not deleted -- #7 corrections 1)`, stepKey);
1661
1859
  if (toggled)
1662
1860
  return { step: toggled, blocked };
@@ -1859,6 +2057,21 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, re
1859
2057
  if (signsIn)
1860
2058
  app.step.action.desired = { ...app.step.action.desired, authIssuer };
1861
2059
  }
2060
+ // #491: only the `app` entry's own step runs `ensureAppLettermintMail`
2061
+ // (`executor.ts`'s own `entry.key === APP_ENTRY_KEY` gate). Unlike
2062
+ // `secretSources`/`authIssuer` above, these two are set unconditionally
2063
+ // -- `null` when no platform is declared, never omitted -- because
2064
+ // `executor.ts` treats the key being *absent* as a stale plan predating
2065
+ // this field and refuses to run (review round 2, finding 3): an omitted
2066
+ // key here would read identically to a stale plan and silently fall
2067
+ // back to the unprotected pre-#491 path instead.
2068
+ if (key === APP_ENTRY_KEY && app.step && app.step.action.desired.enabled !== false) {
2069
+ app.step.action.desired = {
2070
+ ...app.step.action.desired,
2071
+ platformApex: readiness.platformApex,
2072
+ platformLettermintProjectId: readiness.platformLettermintProjectId,
2073
+ };
2074
+ }
1862
2075
  if (app.step)
1863
2076
  steps.push(app.step);
1864
2077
  blocked.push(...app.blocked);
@@ -0,0 +1,114 @@
1
+ import type { DeclaredOrg, EdgeConfig, HostingEntry } from '../org.js';
2
+ import type { DraftStep } from './diff.js';
3
+ /**
4
+ * F8 (#318): one plain Cloudflare Worker per hosting entry that declares
5
+ * `edge`, deployed from a built artifact that entry's own repo publishes
6
+ * (estate DECISIONS.md, 2026-10-04: foundry fetches and verifies it, never
7
+ * checks it out or bundles it). Unlike `planEntryAiKeys`/`planFilesKeys`,
8
+ * there is no renewal window -- a Worker script has no expiry -- so a step
9
+ * is planned again only while the entry's own declared config (`route`,
10
+ * `bindings` or, crucially, `artifactSha256`) differs from what was last
11
+ * applied (recorded as that config's JSON, `edgeAppliedScopeKey`):
12
+ * `artifactSha256` is itself the declared signal that the built bytes
13
+ * changed, so a rebuild under an unchanged URL still redeploys as soon as
14
+ * the entry declares the new digest. "Blocked, not failing" (CLAUDE.md): a
15
+ * host whose executor carries no `WorkersPort` reports every entry's step
16
+ * (apply or retire) as blocked, not planned to a step that would throw.
17
+ */
18
+ export declare const entryEdgeAppliedScopeKey: (entryKey: string) => string;
19
+ export declare const entryEdgeRouteIdScopeKey: (entryKey: string) => string;
20
+ /** The route pattern the recorded route id above was created for -- read to find and delete the *old* route when the declared route pattern changes. */
21
+ export declare const entryEdgeRoutePatternScopeKey: (entryKey: string) => string;
22
+ /**
23
+ * The script name retired, once a retire step completes. `runSteps` only
24
+ * ever writes a step's main recorded id from a *truthy* `resourceId`
25
+ * (`plan-apply.ts`), and retire returns none -- so the main
26
+ * `cloudflare:worker:<key>` id is never cleared by returning from the step,
27
+ * and without this marker the "gone" scan below would plan the same retire
28
+ * forever. Compared against that still-live id, the same convergence
29
+ * `entryAiRetiredScopeKey`/`entryFilesRetiredScopeKey`/
30
+ * `entryNamesRetiredScopeKey` already give their own entry keys.
31
+ */
32
+ export declare const entryEdgeRetiredScopeKey: (entryKey: string) => string;
33
+ /** The script name being uploaded, recorded (and persisted) *before* the upload call -- so a crash mid-upload or a failed route create still leaves a trail to the script rather than orphaning it, and a retry reads the script as foundry's own instead of tripping the `scriptExists` overwrite guard. Cleared once the step records success. */
34
+ export declare const entryEdgeUploadedScopeKey: (entryKey: string) => string;
35
+ /** The zone the recorded route id lives in, as {@link EdgeRouteZone} JSON -- so retire and destroy delete it on *that* zone even after `org.domain` changes or goes null. */
36
+ export declare const entryEdgeRouteZoneScopeKey: (entryKey: string) => string;
37
+ /** A route about to be created, as {@link EdgeRoutePending} JSON, recorded and persisted before `createRoute` -- so a crash or failed persist right after Cloudflare creates it still leaves retire and destroy something to find it by (`findRoute`). Cleared once the route id is recorded. */
38
+ export declare const entryEdgeRoutePendingScopeKey: (entryKey: string) => string;
39
+ export interface EdgeRouteZone {
40
+ id: string;
41
+ apex: string;
42
+ }
43
+ export interface EdgeRoutePending extends EdgeRouteZone {
44
+ pattern: string;
45
+ script: string;
46
+ }
47
+ /** The per-entry scope keys that mark something deployed or half-deployed, or that a retire already handled it -- cleared together once org destroy has torn the Worker down (#318 re-review: the retired marker was left out, so it stayed recorded as a stale leftover after the org itself was gone). */
48
+ export declare const edgeWorkerTraceScopeKeys: (entryKey: string) => string[];
49
+ /**
50
+ * Every entry key of `orgId` with something foundry deployed still live,
51
+ * mapped to the script name recorded for it (`''` if only a route trace is
52
+ * left). Read by the planner's gone-scan and by org destroy alike, so the two
53
+ * agree on what is live: the main `cloudflare:worker:<org>:<entry>` id
54
+ * (unless the retired marker matches it), or any partial-deploy trace -- the
55
+ * uploaded name, a recorded route id, or a pending route -- regardless of
56
+ * the retired marker, since an apply clears that marker before it leaves any
57
+ * of these (#318 re-review).
58
+ */
59
+ export declare function liveEdgeWorkers(orgId: string, recordedIds: Readonly<Record<string, string>>): Map<string, string>;
60
+ /** The first 8 hex characters of org.id's own sha256 -- see {@link edgeWorkerName}'s doc comment. */
61
+ export declare const EDGE_WORKER_ORG_HASH_LENGTH = 8;
62
+ /** Cloudflare's own limit on a Worker script name. */
63
+ export declare const EDGE_WORKER_NAME_MAX_LENGTH = 63;
64
+ /**
65
+ * `${8-hex-char hash of org.id}-${entry.key}-edge`, not `${org.slug}-...`
66
+ * (#318 re-review): a slug and an entry key both allow hyphens, so org
67
+ * `a-b` entry `c` and org `a` entry `b-c` produced the identical name
68
+ * `a-b-c-edge` on the one shared Cloudflare account -- `scriptExists` only
69
+ * caught the second of the two to apply, and two concurrent applies could
70
+ * both pass it. `org.id` is a UUID with no ambiguity of its own, but using
71
+ * it in full would make the fixed part of the name longer than most of the
72
+ * 63-character budget this port's `EDGE_WORKER_NAME_MAX_LENGTH` gives it,
73
+ * so only a short hash of it is used -- `validateEntryEdge` (`org.ts`)
74
+ * checks the resulting name never exceeds that limit.
75
+ */
76
+ export declare const edgeWorkerName: (org: DeclaredOrg, entry: HostingEntry) => string;
77
+ /**
78
+ * Foundry's own fixed compatibility date for every Worker it deploys -- not
79
+ * a per-entry declared value, so it is not part of {@link EdgeWorkerWant}
80
+ * and never triggers a re-plan on its own; bumping it is a foundry release,
81
+ * like {@link RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS} is a fixed Cloudflare
82
+ * Free plan value rather than something an org declares.
83
+ */
84
+ export declare const EDGE_WORKER_COMPATIBILITY_DATE = "2026-09-12";
85
+ export type EdgeWorkerMode = 'apply' | 'retire';
86
+ export interface EdgeWorkerWant {
87
+ name: string;
88
+ artifactUrl: string;
89
+ artifactSha256: string;
90
+ route: string;
91
+ bindings: EdgeConfig['bindings'];
92
+ }
93
+ export declare function wantedEdgeWorker(org: DeclaredOrg, entry: HostingEntry): EdgeWorkerWant;
94
+ type Blocked = Array<{
95
+ description: string;
96
+ key: string;
97
+ }>;
98
+ /**
99
+ * One step per entry whose `edge` config is new or changed, plus one retire
100
+ * step per entry whose Worker is recorded but no longer declared -- either
101
+ * because the entry dropped `edge` or because the entry itself is gone, the
102
+ * same "gone" scan `planEntryAiKeys`/`planFilesKeys`/`planEntryNamesKeys`
103
+ * run over their own recorded keys, so removing a whole entry still cleans
104
+ * up its Worker. `workersAvailable` is the host's own
105
+ * `FoundryPlanApplyOptions.edgeWorkers` (mirrors `jobsAvailable`): without
106
+ * it, every entry's step -- apply or retire -- is reported blocked rather
107
+ * than planned to fail at apply. The caller (`diff.ts`) gates the *apply*
108
+ * steps further, on an active zone, the same way it gates `planRateLimit`.
109
+ */
110
+ export declare function planEdgeWorkers(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, workersAvailable: boolean): {
111
+ steps: DraftStep[];
112
+ blocked: Blocked;
113
+ };
114
+ export {};