@wtfalch/foundry 0.16.16 → 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.
Files changed (67) hide show
  1. package/dist/apply.d.ts +22 -1
  2. package/dist/bootstrap/bootstrap.d.ts +6 -1
  3. package/dist/bootstrap/bootstrap.js +26 -3
  4. package/dist/bootstrap/declare.d.ts +11 -1
  5. package/dist/bootstrap/declare.js +18 -6
  6. package/dist/index.d.ts +11 -6
  7. package/dist/index.js +8 -1
  8. package/dist/migrations/0038_calls_version_pin.sql +8 -0
  9. package/dist/migrations/0039_destroy_record.sql +7 -0
  10. package/dist/migrations/0040_company_included_uuid.sql +72 -0
  11. package/dist/migrations/0041_company_included_uuid_keys.sql +132 -0
  12. package/dist/migrations/0042_mutation_leases.sql +12 -0
  13. package/dist/migrations/0043_platform_names.sql +25 -0
  14. package/dist/migrations/0044_platform_mail_lettermint_project.sql +11 -0
  15. package/dist/org.d.ts +128 -3
  16. package/dist/org.js +186 -1
  17. package/dist/plan/adopt.d.ts +76 -0
  18. package/dist/plan/adopt.js +184 -0
  19. package/dist/plan/agora.d.ts +21 -0
  20. package/dist/plan/agora.js +37 -0
  21. package/dist/plan/broker-entries.d.ts +9 -1
  22. package/dist/plan/broker-entries.js +13 -5
  23. package/dist/plan/chat-matrix.d.ts +1 -2
  24. package/dist/plan/chat-matrix.js +15 -4
  25. package/dist/plan/core-files.d.ts +25 -0
  26. package/dist/plan/core-files.js +25 -0
  27. package/dist/plan/diff.d.ts +27 -3
  28. package/dist/plan/diff.js +264 -86
  29. package/dist/plan/edge-worker.d.ts +114 -0
  30. package/dist/plan/edge-worker.js +201 -0
  31. package/dist/plan/entry-ai-key.d.ts +3 -3
  32. package/dist/plan/entry-files-key.d.ts +3 -3
  33. package/dist/plan/entry-names-key.d.ts +73 -0
  34. package/dist/plan/entry-names-key.js +236 -0
  35. package/dist/plan/executor.d.ts +292 -24
  36. package/dist/plan/executor.js +745 -77
  37. package/dist/plan/index.d.ts +6 -2
  38. package/dist/plan/index.js +3 -1
  39. package/dist/plan/live.d.ts +9 -2
  40. package/dist/plan/login-host.d.ts +13 -2
  41. package/dist/plan/login-host.js +26 -7
  42. package/dist/plan/plan-apply.d.ts +153 -6
  43. package/dist/plan/plan-apply.js +639 -78
  44. package/dist/plan/platform-diff.d.ts +106 -1
  45. package/dist/plan/platform-diff.js +182 -2
  46. package/dist/plan/platform-org.d.ts +45 -1
  47. package/dist/plan/platform-org.js +34 -3
  48. package/dist/plan/platform-providers.d.ts +45 -1
  49. package/dist/plan/platform-providers.js +585 -16
  50. package/dist/plan/providers-readers.d.ts +2 -0
  51. package/dist/plan/providers-readers.js +4 -0
  52. package/dist/plan/rollout.d.ts +52 -0
  53. package/dist/plan/rollout.js +37 -0
  54. package/dist/plan/step-system.d.ts +22 -0
  55. package/dist/plan/step-system.js +38 -0
  56. package/dist/plan.d.ts +3 -0
  57. package/dist/platform.d.ts +22 -3
  58. package/dist/platform.js +6 -0
  59. package/dist/state.d.ts +13 -0
  60. package/dist/store/audit.js +15 -1
  61. package/dist/store/postgres.d.ts +1 -1
  62. package/dist/store/postgres.js +142 -15
  63. package/dist/store/schema.d.ts +202 -5
  64. package/dist/store/schema.js +18 -1
  65. package/dist/store.d.ts +47 -4
  66. package/dist/store.js +9 -1
  67. package/package.json +1 -1
package/dist/plan/diff.js CHANGED
@@ -1,17 +1,20 @@
1
1
  import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
2
2
  import { APP_ENTRY_KEY, ownerMailboxRecorded, ownerMailboxes } from '../org.js';
3
- import { CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
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';
17
+ import { withStepSystem } from './step-system.js';
15
18
  /** The `Plan` (#19's public contract) a `PlanDetail` carries. */
16
19
  export function toPlan(detail) {
17
20
  return {
@@ -42,9 +45,8 @@ function resourceKey(system, kind, key) {
42
45
  * `hosting.issuerApps[]` entry rather than a single singleton, since an org
43
46
  * can declare more than one ZITADEL app. #80 adds two more singletons,
44
47
  * modeled on {@link planIssuer}: `planFilesOrganisation` and
45
- * `planAiOrganisation`, each planned before its own system's limit step
46
- * ({@link planStorage}, {@link planAiBudget}) so an org has somewhere for a
47
- * limit to land, and AI use is switched on, before either limit is ever set.
48
+ * `planAiOrganisation`, the second planned before {@link planAiBudget} so AI
49
+ * use is switched on before its own limit is ever set.
48
50
  * `planOwners` below adds one more singleton, in a different shape from the
49
51
  * rest: the org's owner, invited by ZITADEL email, idempotent on the
50
52
  * *recorded* resource id (`recordedIds`, sourced from
@@ -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) {
@@ -142,9 +149,6 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
142
149
  const filesOrganisation = planFilesOrganisation(org, byKey, claimed, platformReadiness.filesReady, platformReadiness.missingKeys.files, platformReadiness.noKeyReader);
143
150
  if (filesOrganisation.step)
144
151
  plannedSteps.push(filesOrganisation.step);
145
- const storage = planStorage(org, byKey, claimed, platformReadiness.filesReady, platformReadiness.missingKeys.files, platformReadiness.noKeyReader);
146
- if (storage.step)
147
- plannedSteps.push(storage.step);
148
152
  const aiOrganisation = planAiOrganisation(org, byKey, claimed, platformReadiness.aiReady, platformReadiness.missingKeys.ai, platformReadiness.noKeyReader);
149
153
  if (aiOrganisation.step)
150
154
  plannedSteps.push(aiOrganisation.step);
@@ -200,6 +204,16 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
200
204
  // F6b: after the owner, the apps and the files organisation it needs.
201
205
  const filesKeys = planFilesKeys(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)());
202
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);
203
217
  const tunnel = planTunnel(org, byKey, claimed, recordedIds);
204
218
  // #238: a tunnel that is being provisioned waits for an active zone; parking
205
219
  // one never does.
@@ -257,6 +271,26 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
257
271
  const jobsRegistration = planJobsRegistration(org, recordedIds, jobsAvailable || platformReadiness.jobsReady, platformReadiness.missingKeys.jobs, platformReadiness.noKeyReader);
258
272
  if (jobsRegistration.step)
259
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
+ }
260
294
  // #10 (resolved): "foundry manages only what the declared org names";
261
295
  // anything left unclaimed is listed, never proposed for deletion.
262
296
  const unmanaged = live
@@ -277,6 +311,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
277
311
  unmanaged.push({ description: aiKey.blocked, key: 'ai:key' });
278
312
  unmanaged.push(...filesKeys.blocked);
279
313
  unmanaged.push(...entryAiKeys.blocked);
314
+ unmanaged.push(...edgeWorkers.blocked);
315
+ unmanaged.push(...entryNamesKeys.blocked);
316
+ unmanaged.push(...namesReservedLabels.blocked);
280
317
  // Same for a jobs registration that cannot run (no scheduler wired) or must
281
318
  // not run again (minted, never delivered).
282
319
  if (jobsRegistration.blocked) {
@@ -313,8 +350,6 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
313
350
  if (aiOrganisation.blocked) {
314
351
  unmanaged.push({ description: aiOrganisation.blocked, key: 'ai:organisation' });
315
352
  }
316
- if (storage.blocked)
317
- unmanaged.push({ description: storage.blocked, key: 'files:storage' });
318
353
  if (aiBudget.blocked)
319
354
  unmanaged.push({ description: aiBudget.blocked, key: 'ai:budget' });
320
355
  // #238: a pending zone, reported here with its nameservers rather than
@@ -326,7 +361,12 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
326
361
  unmanaged.push(...app.blocked);
327
362
  unmanaged.push(...brokerEntries.blocked);
328
363
  unmanaged.push(...bouleBroker.blocked);
329
- return { orgId: org.id, plannedSteps, unmanaged, versionChanges };
364
+ return {
365
+ orgId: org.id,
366
+ plannedSteps: plannedSteps.map(withStepSystem),
367
+ unmanaged,
368
+ versionChanges,
369
+ };
330
370
  }
331
371
  /**
332
372
  * The org's own ZITADEL org (issuer), a per-org singleton like every other
@@ -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.
@@ -990,11 +1144,8 @@ function notReadyReason(service, keyName, missingKeyRef, noKeyReader) {
990
1144
  : `the platform's ${service} host is off or not yet applied`;
991
1145
  }
992
1146
  /**
993
- * #80: every org's own organisation on Files, a per-org singleton resource
994
- * planned before {@link planStorage} -- `setQuota`/`setBudget` 404 against an
995
- * org Files has never provisioned (`files/packages/sdk/src/client.ts`'s
996
- * tenant routes need the row `provisionOrganisation` creates). Modeled on
997
- * {@link planIssuer}: nothing to diff once it exists -- Files' own
1147
+ * #80: every org's own organisation on Files, a per-org singleton resource.
1148
+ * Modeled on {@link planIssuer}: nothing to diff once it exists -- Files' own
998
1149
  * `provisionOrganisation` is safely repeatable (`executor.ts`'s
999
1150
  * `filesOrganisation` calls it every time regardless), so the only planned
1000
1151
  * state is "not provisioned yet". The operator-driven `ceilingBytes`/
@@ -1076,9 +1227,14 @@ function planFilesOrganisation(org, byKey, claimed, filesReady, missingKeyRef, n
1076
1227
  * regardless), so the only planned state is "not provisioned yet".
1077
1228
  * `AI_DEFAULT_OFFERS` is this campaign's Decision for what "AI use is
1078
1229
  * switched on" means by default; `limitMicros` seeds the organisation's
1079
- * first budget row with whatever the org already declares (including
1080
- * `null` for uncapped), so a brand new org never needs a follow-up
1081
- * {@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.
1082
1238
  *
1083
1239
  * #177: blocked, not planned, while `aiReady` is false -- the platform's own
1084
1240
  * AI host (#172) is off or not yet applied.
@@ -1104,12 +1260,14 @@ function planAiOrganisation(org, byKey, claimed, aiReady, missingKeyRef, noKeyRe
1104
1260
  key: org.id,
1105
1261
  desired: {
1106
1262
  provision: true,
1107
- limitMicros: org.ai.limitMicros,
1263
+ limitMicros: effectiveAiLimitMicros(org.ai.limitMicros),
1108
1264
  offers: [...AI_DEFAULT_OFFERS],
1109
1265
  },
1110
1266
  },
1111
1267
  step: {
1112
- 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`,
1113
1271
  destroys: false,
1114
1272
  migrates: false,
1115
1273
  },
@@ -1117,56 +1275,20 @@ function planAiOrganisation(org, byKey, claimed, aiReady, missingKeyRef, noKeyRe
1117
1275
  blocked: null,
1118
1276
  };
1119
1277
  }
1120
- /**
1121
- * #27: the org's own spend limit on Files, a per-org singleton resource in
1122
- * the same shape as issuer/mail/chat above. Setting a limit is a config
1123
- * write, never destructive or migrating (#27 "Notes": "safe, idempotent and
1124
- * reversible"). A missing live resource reads as uncapped (`null`), the same
1125
- * value a freshly-declared org's `storage` starts at, so a brand new org with
1126
- * no limits declared produces no step.
1127
- *
1128
- * #177: silently plans nothing while there is no organisation to size a limit
1129
- * on yet and `filesReady` is false -- {@link planFilesOrganisation} already
1130
- * reports the platform's files host as missing once.
1131
- */
1132
- function planStorage(org, byKey, claimed, filesReady, missingKeyRef, noKeyReader = false) {
1133
- const key = resourceKey('files', 'storage', org.id);
1134
- const live = byKey.get(key);
1135
- if (live)
1136
- claimed.add(key);
1137
- if (!filesReady && !live)
1138
- return { step: null, blocked: null };
1139
- const desired = { limitBytes: org.storage.limitBytes, limitMicros: org.storage.limitMicros };
1140
- const liveLimitBytes = live ? (live.config.limitBytes ?? null) : null;
1141
- const liveLimitMicros = live ? (live.config.limitMicros ?? null) : null;
1142
- if (liveLimitBytes === desired.limitBytes && liveLimitMicros === desired.limitMicros) {
1143
- return { step: null, blocked: null };
1144
- }
1145
- // A live resource with drift needs the platform's key to change; blocked, not planned to throw at apply.
1146
- if (!filesReady) {
1147
- return {
1148
- step: null,
1149
- blocked: `${org.slug}'s storage limit change is blocked, not planned: ${notReadyReason('files', 'files management key', missingKeyRef, noKeyReader)}`,
1150
- };
1151
- }
1152
- return {
1153
- blocked: null,
1154
- step: {
1155
- action: { system: 'files', kind: 'storage', key: org.id, desired },
1156
- step: {
1157
- description: `set ${org.slug}'s storage limit to ${desired.limitBytes ?? 'uncapped'} bytes / ${desired.limitMicros ?? 'uncapped'} micros`,
1158
- destroys: false,
1159
- migrates: false,
1160
- },
1161
- },
1162
- };
1163
- }
1164
1278
  /**
1165
1279
  * #27: the org's own spend limit on AI, a per-org singleton resource in the
1166
- * same shape as {@link planStorage}. Planned after {@link planAiOrganisation}
1280
+ * same singleton shape as issuer/mail/chat above. Planned after {@link planAiOrganisation}
1167
1281
  * (#80), which already seeds a brand new org's first budget row from this
1168
- * same `org.ai.limitMicros` -- this planner only fires again once that row's
1169
- * 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.
1170
1292
  *
1171
1293
  * #177: silently plans nothing while there is no organisation to size a
1172
1294
  * budget on yet and `aiReady` is false -- {@link planAiOrganisation} already
@@ -1179,7 +1301,8 @@ function planAiBudget(org, byKey, claimed, aiReady, missingKeyRef, noKeyReader =
1179
1301
  claimed.add(key);
1180
1302
  if (!aiReady && !live)
1181
1303
  return { step: null, blocked: null };
1182
- const desired = { limitMicros: org.ai.limitMicros };
1304
+ const isDefault = org.ai.limitMicros === null;
1305
+ const desired = { limitMicros: effectiveAiLimitMicros(org.ai.limitMicros) };
1183
1306
  const liveLimitMicros = live ? (live.config.limitMicros ?? null) : null;
1184
1307
  if (liveLimitMicros === desired.limitMicros)
1185
1308
  return { step: null, blocked: null };
@@ -1194,7 +1317,9 @@ function planAiBudget(org, byKey, claimed, aiReady, missingKeyRef, noKeyReader =
1194
1317
  step: {
1195
1318
  action: { system: 'ai', kind: 'budget', key: org.id, desired },
1196
1319
  step: {
1197
- 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`,
1198
1323
  destroys: false,
1199
1324
  migrates: false,
1200
1325
  },
@@ -1469,13 +1594,25 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
1469
1594
  if (liveEnv.AGORA_CONFIG === undefined && !drift.includes('AGORA_CONFIG')) {
1470
1595
  drift.push('AGORA_CONFIG');
1471
1596
  }
1472
- if (drift.length === 0)
1597
+ // #442: `drift` alone, once this step has ever run, is comparing `env`
1598
+ // against Coolify's own env store -- which the executor's `bulkUpsertEnvs`
1599
+ // leaves matching `env` the moment the write succeeds, restarted or not.
1600
+ // So once applied, drift reads empty forever even if the running process
1601
+ // was never restarted onto it (a crash between the write and the restart,
1602
+ // or simply never restarting at all). `AGORA_ACTIVATED_ENV_SCOPE_KEY` is
1603
+ // the executor's own record that a restart confirmed the process is
1604
+ // actually serving this exact `env`; while it disagrees, the step stays
1605
+ // planned no matter what Coolify's env store already shows.
1606
+ const activated = recordedIds[AGORA_ACTIVATED_ENV_SCOPE_KEY] === agoraEnvRevision(env);
1607
+ if (drift.length === 0 && activated)
1473
1608
  return { step: null, blocked: null };
1474
1609
  return {
1475
1610
  step: {
1476
1611
  action,
1477
1612
  step: {
1478
- description: `update ${org.slug}'s Agora config (${drift.join(', ')} missing or different)`,
1613
+ description: drift.length > 0
1614
+ ? `update ${org.slug}'s Agora config (${drift.join(', ')} missing or different)`
1615
+ : `activate ${org.slug}'s Agora config -- written but not yet confirmed restarted`,
1479
1616
  destroys: false,
1480
1617
  migrates: false,
1481
1618
  },
@@ -1692,6 +1829,32 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
1692
1829
  key: blockedKey(`env:${name}`),
1693
1830
  })))
1694
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
+ }
1695
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);
1696
1859
  if (toggled)
1697
1860
  return { step: toggled, blocked };
@@ -1894,6 +2057,21 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, re
1894
2057
  if (signsIn)
1895
2058
  app.step.action.desired = { ...app.step.action.desired, authIssuer };
1896
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
+ }
1897
2075
  if (app.step)
1898
2076
  steps.push(app.step);
1899
2077
  blocked.push(...app.blocked);