@wtfalch/foundry 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,10 @@
1
1
  import type { PlanApply } from '../apply.js';
2
- import type { OrgId } from '../org.js';
2
+ import type { DeclaredOrg, OrgId } from '../org.js';
3
3
  import type { Plan } from '../plan.js';
4
4
  import type { AppliedState } from '../state.js';
5
5
  import type { DeclaredOrgStore } from '../store.js';
6
6
  import { type StepAction } from './diff.js';
7
- import { type LiveReaders } from './live.js';
7
+ import { type LiveReaders, type LiveResource } from './live.js';
8
8
  /** Who did it, for `@wtfalch/audit`'s "who, when, the plan, the result" (#9, #19). */
9
9
  export interface AuditActor {
10
10
  readonly id: string;
@@ -37,6 +37,14 @@ export interface AuditLog {
37
37
  reason?: string;
38
38
  at: Date;
39
39
  }): Promise<void>;
40
+ /** `FoundryPlanApply.destroy`'s own record -- no `Plan` involved, so this is its own entry shape rather than a reuse of `recordApply`'s. */
41
+ recordDestroy(entry: {
42
+ actor: AuditActor;
43
+ orgId: OrgId;
44
+ outcome: 'destroyed' | 'refused' | 'failed';
45
+ reason?: string;
46
+ at: Date;
47
+ }): Promise<void>;
40
48
  }
41
49
  /**
42
50
  * The seam onto `@wtfalch/authz`, gating `apply` only — `plan` is a read
@@ -47,18 +55,101 @@ export interface AuditLog {
47
55
  */
48
56
  export interface AuthzGate {
49
57
  canApply(actor: AuditActor, orgId: OrgId): Promise<boolean>;
58
+ /**
59
+ * Gates `destroy` -- deleting every resource foundry made for an org, then
60
+ * the org itself, is a stronger action than applying config, so it is its
61
+ * own permission rather than reusing `canApply`.
62
+ */
63
+ canDestroy(actor: AuditActor, orgId: OrgId): Promise<boolean>;
64
+ }
65
+ /**
66
+ * What {@link StepExecutor.execute} needs beyond the {@link StepAction}
67
+ * itself to act: the declared org (a real executor names its own live
68
+ * resources after `org.slug`/`org.bespokeAppRepo`/etc., not just the bare
69
+ * ids a `StepAction` carries) and read/write access to this org's *scope*
70
+ * ids — a Coolify project uuid, the org's own ZITADEL org/project id, a
71
+ * Cloudflare zone id — the ids one step's executor resolves or creates that a
72
+ * later step (or a later apply) needs again (executor decisions,
73
+ * `.claude/decisions-2026-09-24-executor.md`: "Scope ids: a Coolify project
74
+ * per org (`serviceIds['coolify_project']`) ... Created resources are
75
+ * recorded under `system:kind:key`").
76
+ *
77
+ * Decision (executor.ts, #20): rather than backing `recordScopeId` with its
78
+ * own direct `DeclaredOrgStore.putAppliedState` call, `recordedIds`/
79
+ * `recordScopeId` below are backed by the very `serviceIds` map `runSteps`
80
+ * already accumulates and persists once, atomically, at the end of a run. A
81
+ * direct store write from inside `execute` would race that final write:
82
+ * `runSteps` captures `previous?.serviceIds` once before the loop starts, so
83
+ * its own end-of-loop `store.putAppliedState` would silently clobber
84
+ * anything an executor wrote to the store mid-loop. Routing scope ids
85
+ * through the same in-memory map both writes to removes the race entirely,
86
+ * and also means `recordedIds` sees a resource id `runSteps` itself recorded
87
+ * for an *earlier* step in this same run (e.g. the ZITADEL org provisioned
88
+ * by an `issuer` step, needed by a later `app` step) without any extra
89
+ * plumbing.
90
+ */
91
+ export interface StepExecutionContext {
92
+ readonly org: DeclaredOrg;
93
+ /**
94
+ * Every id foundry has recorded for this org so far — both `system:kind:key`
95
+ * resource ids (`runSteps` records these itself from `execute`'s own return
96
+ * value) and flat provider *scope* ids a step recorded via
97
+ * {@link recordScopeId}. A snapshot at the moment it's called, but since
98
+ * `runSteps` runs one step at a time and mutates the same underlying map
99
+ * this reads from, it always reflects everything recorded so far in this
100
+ * run, plus whatever a previous apply already persisted.
101
+ */
102
+ recordedIds(): Readonly<Record<string, string>>;
103
+ /**
104
+ * Records a provider *scope* id — `coolify_project`, `zitadel_org`,
105
+ * `zitadel_project`, `cloudflare_zone` — alongside whatever `system:kind:key`
106
+ * resource id `execute`'s own return value records. `key` must carry no
107
+ * `:`, the same rule `state.ts`'s `AppliedState.serviceIds` documents, so a
108
+ * scope key can never collide with the `system:kind:key` form.
109
+ */
110
+ recordScopeId(key: string, id: string): void;
50
111
  }
51
112
  /**
52
113
  * Makes one {@link StepAction} true against a real system. No implementation
53
114
  * ships in this package — #19's job is the seam and the refusal rules,
54
115
  * proven against fakes; a real executor (calling Coolify/Zitadel/Cloudflare)
55
- * is #20's "provision Company Factory end to end".
116
+ * is #20's "provision Company Factory end to end" (`executor.ts`'s
117
+ * `FoundryStepExecutor`).
56
118
  */
57
119
  export interface StepExecutor {
58
- execute(action: StepAction): Promise<{
120
+ execute(action: StepAction, context: StepExecutionContext): Promise<{
59
121
  resourceId?: string;
60
122
  } | undefined>;
61
123
  }
124
+ /**
125
+ * One resource `FoundryPlanApply.destroy` has decided to tear down: an entry
126
+ * from `AppliedState.serviceIds` (`resourceKey`'s `system:kind:key` form, or
127
+ * one of the two ZITADEL scope keys, `zitadel_project`/`zitadel_org`),
128
+ * resolved back to the provider id it was recorded under.
129
+ */
130
+ export interface DestroyTarget {
131
+ system: LiveResource['system'];
132
+ kind: string;
133
+ /** The foundry key this was recorded under: an org id, a declared issuer app's name, or the scope key itself for `kind: 'project' | 'org'`. */
134
+ key: string;
135
+ /** The provider's own id -- exactly what `AppliedState.serviceIds` holds under this resource's key. */
136
+ id: string;
137
+ }
138
+ /**
139
+ * Deletes one resource {@link DestroyTarget} names. The teardown half of
140
+ * {@link StepExecutor}: where `execute` makes a `StepAction` true, `destroy`
141
+ * undoes exactly the one resource it is given, one at a time, in the order
142
+ * `FoundryPlanApply.destroy` calls it. A provider 404 (already gone) counts
143
+ * as deleted, never an error -- a destroy interrupted after a resource was
144
+ * deleted but before its `serviceIds` entry was removed must not fail the
145
+ * same way twice on retry.
146
+ */
147
+ export interface StepDestroyer {
148
+ destroy(target: DestroyTarget, context: {
149
+ readonly org: DeclaredOrg;
150
+ readonly recordedIds: Readonly<Record<string, string>>;
151
+ }): Promise<void>;
152
+ }
62
153
  export declare class DeclaredOrgNotFoundError extends Error {
63
154
  constructor(orgId: OrgId);
64
155
  }
@@ -73,10 +164,15 @@ export declare class ConfirmationRequiredError extends Error {
73
164
  export declare class NotAuthorizedError extends Error {
74
165
  constructor(actor: AuditActor, orgId: OrgId);
75
166
  }
167
+ /** The goal line's own gate: only a test org can be destroyed. */
168
+ export declare class NotATestOrgError extends Error {
169
+ constructor(orgId: OrgId);
170
+ }
76
171
  export interface FoundryPlanApplyOptions {
77
172
  store: DeclaredOrgStore;
78
173
  readers: LiveReaders;
79
174
  executor: StepExecutor;
175
+ destroyer: StepDestroyer;
80
176
  audit: AuditLog;
81
177
  authz: AuthzGate;
82
178
  /** The operator running this plan/apply — every audit row's "who" (#9, #19). */
@@ -94,6 +190,7 @@ export declare class FoundryPlanApply implements PlanApply {
94
190
  private readonly store;
95
191
  private readonly readers;
96
192
  private readonly executor;
193
+ private readonly destroyer;
97
194
  private readonly audit;
98
195
  private readonly authz;
99
196
  private readonly actor;
@@ -101,7 +198,20 @@ export declare class FoundryPlanApply implements PlanApply {
101
198
  constructor(options: FoundryPlanApplyOptions);
102
199
  plan(id: OrgId): Promise<Plan>;
103
200
  apply(plan: Plan, confirmedOrgName: string): Promise<AppliedState>;
201
+ /**
202
+ * Deletes every resource foundry recorded for `id`, in reverse creation
203
+ * order, then the declared org and its applied state themselves (goal
204
+ * line). Refused unless the stored `DeclaredOrg` has `test: true` and
205
+ * unless `confirmedOrgName` matches the org's `displayName` -- the same
206
+ * confirmation `apply` requires for a destructive step (#9), required here
207
+ * unconditionally since destroying an org is the most destructive step
208
+ * there is. A failure partway through stops and rethrows, leaving the
209
+ * declared org in place with whatever remains recorded in its applied
210
+ * state, so a retried `destroy` only tears down what's left.
211
+ */
212
+ destroy(id: OrgId, confirmedOrgName: string): Promise<void>;
104
213
  private refuse;
214
+ private refuseDestroy;
105
215
  private runSteps;
106
216
  private computeFreshPlan;
107
217
  }
@@ -27,6 +27,13 @@ export class NotAuthorizedError extends Error {
27
27
  this.name = 'NotAuthorizedError';
28
28
  }
29
29
  }
30
+ /** The goal line's own gate: only a test org can be destroyed. */
31
+ export class NotATestOrgError extends Error {
32
+ constructor(orgId) {
33
+ super(`org ${orgId} is not marked test; only a test org can be destroyed`);
34
+ this.name = 'NotATestOrgError';
35
+ }
36
+ }
30
37
  /**
31
38
  * The #19 implementation of {@link PlanApply}: computes a plan against live
32
39
  * state, and refuses to apply one that has gone stale, one that touches a
@@ -37,6 +44,7 @@ export class FoundryPlanApply {
37
44
  store;
38
45
  readers;
39
46
  executor;
47
+ destroyer;
40
48
  audit;
41
49
  authz;
42
50
  actor;
@@ -45,6 +53,7 @@ export class FoundryPlanApply {
45
53
  this.store = options.store;
46
54
  this.readers = options.readers;
47
55
  this.executor = options.executor;
56
+ this.destroyer = options.destroyer;
48
57
  this.audit = options.audit;
49
58
  this.authz = options.authz;
50
59
  this.actor = options.actor;
@@ -74,6 +83,71 @@ export class FoundryPlanApply {
74
83
  }
75
84
  return this.runSteps(org, plan, detail);
76
85
  }
86
+ /**
87
+ * Deletes every resource foundry recorded for `id`, in reverse creation
88
+ * order, then the declared org and its applied state themselves (goal
89
+ * line). Refused unless the stored `DeclaredOrg` has `test: true` and
90
+ * unless `confirmedOrgName` matches the org's `displayName` -- the same
91
+ * confirmation `apply` requires for a destructive step (#9), required here
92
+ * unconditionally since destroying an org is the most destructive step
93
+ * there is. A failure partway through stops and rethrows, leaving the
94
+ * declared org in place with whatever remains recorded in its applied
95
+ * state, so a retried `destroy` only tears down what's left.
96
+ */
97
+ async destroy(id, confirmedOrgName) {
98
+ const authorized = await this.authz.canDestroy(this.actor, id);
99
+ if (!authorized) {
100
+ await this.refuseDestroy(id, 'not authorized');
101
+ throw new NotAuthorizedError(this.actor, id);
102
+ }
103
+ const org = await this.store.get(id);
104
+ if (!org)
105
+ throw new DeclaredOrgNotFoundError(id);
106
+ if (!org.test) {
107
+ await this.refuseDestroy(id, 'org is not marked test; only a test org can be destroyed');
108
+ throw new NotATestOrgError(id);
109
+ }
110
+ if (confirmedOrgName.trim() !== org.displayName.trim()) {
111
+ await this.refuseDestroy(id, "org's name not typed correctly");
112
+ throw new ConfirmationRequiredError(id);
113
+ }
114
+ const state = await this.store.getAppliedState(id);
115
+ const serviceIds = { ...(state?.serviceIds ?? {}) };
116
+ const targets = destroyTargets(serviceIds);
117
+ try {
118
+ for (const target of targets) {
119
+ // A provider 404 counts as deleted -- `StepDestroyer.destroy`'s own
120
+ // doc comment -- so nothing here needs to special-case "already
121
+ // gone" itself.
122
+ await this.destroyer.destroy(target, { org, recordedIds: { ...serviceIds } });
123
+ delete serviceIds[destroyServiceIdKey(target)];
124
+ await this.store.putAppliedState({
125
+ orgId: id,
126
+ serviceIds,
127
+ checkpoints: state?.checkpoints ?? [],
128
+ failedFields: state?.failedFields ?? [],
129
+ lastApplied: state?.lastApplied ?? null,
130
+ });
131
+ }
132
+ await this.store.delete(id);
133
+ }
134
+ catch (e) {
135
+ await this.audit.recordDestroy({
136
+ actor: this.actor,
137
+ orgId: id,
138
+ outcome: 'failed',
139
+ reason: e instanceof Error ? e.message : String(e),
140
+ at: this.now(),
141
+ });
142
+ throw e;
143
+ }
144
+ await this.audit.recordDestroy({
145
+ actor: this.actor,
146
+ orgId: id,
147
+ outcome: 'destroyed',
148
+ at: this.now(),
149
+ });
150
+ }
77
151
  async refuse(plan, reason) {
78
152
  await this.audit.recordApply({
79
153
  actor: this.actor,
@@ -84,16 +158,41 @@ export class FoundryPlanApply {
84
158
  at: this.now(),
85
159
  });
86
160
  }
161
+ async refuseDestroy(orgId, reason) {
162
+ await this.audit.recordDestroy({
163
+ actor: this.actor,
164
+ orgId,
165
+ outcome: 'refused',
166
+ reason,
167
+ at: this.now(),
168
+ });
169
+ }
87
170
  async runSteps(org, plan, detail) {
88
171
  const orgId = plan.orgId;
89
172
  const previous = await this.store.getAppliedState(orgId);
90
173
  const serviceIds = { ...(previous?.serviceIds ?? {}) };
91
174
  const checkpoints = [];
175
+ // Backed by the very `serviceIds` map this method persists once, at the
176
+ // end — see `StepExecutionContext`'s doc comment for why.
177
+ const context = {
178
+ org,
179
+ recordedIds: () => ({ ...serviceIds }),
180
+ recordScopeId: (key, id) => {
181
+ serviceIds[key] = id;
182
+ },
183
+ };
92
184
  try {
93
185
  for (const planned of detail.plannedSteps) {
94
- const result = await this.executor.execute(planned.action);
95
- if (result?.resourceId)
96
- serviceIds[planned.action.kind] = result.resourceId;
186
+ const result = await this.executor.execute(planned.action, context);
187
+ // `system:kind:key`, not just `kind`: two steps can share a `kind`
188
+ // (an org's several declared ZITADEL apps, #71) and would otherwise
189
+ // overwrite each other's id. `providers-readers.ts`'s
190
+ // `storeRecordedIds` reads this same key back to match a plan's live
191
+ // resources to what foundry itself created here.
192
+ if (result?.resourceId) {
193
+ const { system, kind, key } = planned.action;
194
+ serviceIds[`${system}:${kind}:${key}`] = result.resourceId;
195
+ }
97
196
  checkpoints.push({ step: planned.step.description, completedAt: this.now().toISOString() });
98
197
  }
99
198
  }
@@ -105,7 +204,7 @@ export class FoundryPlanApply {
105
204
  orgId,
106
205
  serviceIds,
107
206
  checkpoints,
108
- failedFields: previous?.failedFields ?? [],
207
+ failedFields: failedFields(org, detail, checkpoints.length),
109
208
  lastApplied: previous?.lastApplied ?? null,
110
209
  });
111
210
  await this.audit.recordApply({
@@ -122,14 +221,7 @@ export class FoundryPlanApply {
122
221
  orgId,
123
222
  serviceIds,
124
223
  checkpoints,
125
- // Carried forward, never recomputed. #18 added `failedFields` and left
126
- // "whether apply throws, partially applies, or does both" to #19, but
127
- // this implementation was written before the field existed and has no
128
- // opinion on it: `computePlanDetail` plans only the issuer, mail and
129
- // chat, so it cannot yet say which declared fields it failed to make
130
- // true. Writing `[]` here would claim it had checked. #20 wires the
131
- // full provisioning surface and is what computes this list.
132
- failedFields: previous?.failedFields ?? [],
224
+ failedFields: failedFields(org, detail, detail.plannedSteps.length),
133
225
  lastApplied: { at: this.now().toISOString(), declaredOrgVersion: hashOrg(org) },
134
226
  };
135
227
  await this.store.putAppliedState(applied);
@@ -146,11 +238,156 @@ export class FoundryPlanApply {
146
238
  const org = await this.store.get(id);
147
239
  if (!org)
148
240
  throw new DeclaredOrgNotFoundError(id);
149
- const live = await readLiveResources(id, this.readers);
150
- const detail = computePlanDetail(org, live);
241
+ const [live, applied] = await Promise.all([
242
+ readLiveResources(id, this.readers),
243
+ this.store.getAppliedState(id),
244
+ ]);
245
+ const detail = computePlanDetail(org, live, applied?.serviceIds ?? {});
151
246
  return { org, plan: toPlan(detail), detail };
152
247
  }
153
248
  }
249
+ /** `AppliedState.serviceIds`' two flat ZITADEL scope keys (`executor.ts`'s `ensureZitadelProject`/`zitadelIssuer`) -- no `:`, unlike a resource key. */
250
+ const ZITADEL_PROJECT_SCOPE_KEY = 'zitadel_project';
251
+ const ZITADEL_ORG_SCOPE_KEY = 'zitadel_org';
252
+ /**
253
+ * Every resource `FoundryPlanApply.destroy` tears down, built from
254
+ * `AppliedState.serviceIds`, in the order the goal line and this campaign's
255
+ * report settle on: the Cloudflare tunnel first (stop routing traffic to the
256
+ * app before tearing it down), then the org's own Coolify database and app,
257
+ * then the org's customers ZITADEL org (plan gap A9), then every ZITADEL app
258
+ * foundry recorded, then the ZITADEL project, then the
259
+ * ZITADEL org itself -- the one resource every other one in this org lives
260
+ * inside, so it goes last.
261
+ *
262
+ * ZITADEL apps are read directly from `serviceIds` (any key with the
263
+ * `zitadel:app:` prefix), not from the declared org's current
264
+ * `hosting.issuerApps` -- destroy tears down everything foundry actually
265
+ * recorded for this org, including an app since removed from the declared
266
+ * list (drift `diff.ts`'s own `planIssuerApps` doc comment already
267
+ * acknowledges: "removing an app from `issuerApps` entirely produces no step
268
+ * at all").
269
+ *
270
+ * The Cloudflare zone and the Coolify project are deliberately absent: no
271
+ * zone-delete or Coolify-project-delete method was named for this campaign
272
+ * (Finding, this campaign's report), so destroy leaves both in place.
273
+ */
274
+ function destroyTargets(serviceIds) {
275
+ // Reverse-teardown order: everything under one of these prefixes is
276
+ // collected in this order, then every ZITADEL app (there can be several,
277
+ // #71), then the two scope ids last.
278
+ const ORDERED_PREFIXES = [
279
+ 'cloudflare:tunnel:',
280
+ 'coolify:database:',
281
+ 'coolify:app:',
282
+ 'zitadel:customers:',
283
+ ];
284
+ const targets = [];
285
+ for (const prefix of ORDERED_PREFIXES) {
286
+ for (const [resourceKey, id] of Object.entries(serviceIds)) {
287
+ if (!resourceKey.startsWith(prefix))
288
+ continue;
289
+ const [system, kind] = prefix.split(':');
290
+ targets.push({ system, kind, key: resourceKey.slice(prefix.length), id });
291
+ }
292
+ }
293
+ for (const [resourceKey, id] of Object.entries(serviceIds)) {
294
+ if (!resourceKey.startsWith('zitadel:app:'))
295
+ continue;
296
+ targets.push({
297
+ system: 'zitadel',
298
+ kind: 'app',
299
+ key: resourceKey.slice('zitadel:app:'.length),
300
+ id,
301
+ });
302
+ }
303
+ for (const [kind, scopeKey] of [
304
+ ['project', ZITADEL_PROJECT_SCOPE_KEY],
305
+ ['org', ZITADEL_ORG_SCOPE_KEY],
306
+ ]) {
307
+ const id = serviceIds[scopeKey];
308
+ if (id)
309
+ targets.push({ system: 'zitadel', kind, key: scopeKey, id });
310
+ }
311
+ return targets;
312
+ }
313
+ /** The `AppliedState.serviceIds` key a completed {@link DestroyTarget} should be removed from. */
314
+ function destroyServiceIdKey(target) {
315
+ if (target.kind === 'project' || target.kind === 'org')
316
+ return target.key;
317
+ return `${target.system}:${target.kind}:${target.key}`;
318
+ }
319
+ /**
320
+ * The declared field each planned step makes true, keyed `system:kind` as
321
+ * `diff.ts` plans it. A kind not listed here falls back to `system.kind`, so
322
+ * a new planner step still names something rather than vanishing.
323
+ */
324
+ const STEP_FIELDS = {
325
+ 'zitadel:issuer': 'issuer.usesDomain',
326
+ 'zitadel:owner': 'owner',
327
+ 'zitadel:customers': 'hosting.issuerApps',
328
+ 'coolify:mail': 'mail',
329
+ 'coolify:chat': 'chat.usesDomain',
330
+ 'files:storage': 'storage',
331
+ 'ai:budget': 'ai',
332
+ 'cms:site': 'site',
333
+ 'coolify:app': 'hosting.enabled',
334
+ 'coolify:database': 'hosting.database',
335
+ 'cloudflare:tunnel': 'hosting.tunnel',
336
+ 'cloudflare:zone': 'hosting.usesDomain',
337
+ };
338
+ function stepField(action) {
339
+ // An org declares several issuer apps, each keyed by its name (#71).
340
+ if (action.system === 'zitadel' && action.kind === 'app') {
341
+ return `hosting.issuerApps.${action.key}`;
342
+ }
343
+ return STEP_FIELDS[`${action.system}:${action.kind}`] ?? `${action.system}.${action.kind}`;
344
+ }
345
+ /**
346
+ * #25: the declared fields this apply could not make true, written fresh on
347
+ * every apply rather than carried forward.
348
+ *
349
+ * Three sources, in this order:
350
+ * - fields no planner step exists for yet, so no apply can make them true:
351
+ * SSO, guests and solo when turned on, calls on the org's domain, a
352
+ * bespoke app repo, an adopted app in the `repo` form (#7's `#15` comment:
353
+ * "declared but refused by `apply`"), and hosting on a domain the org has
354
+ * not recorded;
355
+ * - chat's reverse re-key, which the planner surfaces but which is not built
356
+ * (#7 amendment 3), so it counts as failed even when no step threw;
357
+ * - on a failed apply, the field of every planned step from the one that
358
+ * threw onwards (`completed` is how many steps finished).
359
+ */
360
+ function failedFields(org, detail, completed) {
361
+ const failed = new Set();
362
+ if (org.sso.enabled)
363
+ failed.add('sso.enabled');
364
+ if (org.guests.enabled)
365
+ failed.add('guests.enabled');
366
+ if (org.solo.enabled)
367
+ failed.add('solo.enabled');
368
+ if (org.bespokeAppRepo !== null)
369
+ failed.add('bespokeAppRepo');
370
+ if (org.adoptedApp?.form === 'repo')
371
+ failed.add('adoptedApp');
372
+ if (org.calls.usesDomain)
373
+ failed.add('calls.usesDomain');
374
+ // `planZone` plans nothing without a domain to put the zone on.
375
+ if (org.hosting.usesDomain && org.domain === null)
376
+ failed.add('hosting.usesDomain');
377
+ for (const { action, step } of detail.plannedSteps) {
378
+ // Provisioning chat also desires `usesDomain: false`; only the reverse
379
+ // re-key is a destructive step with that value.
380
+ const reverseReKey = action.system === 'coolify' &&
381
+ action.kind === 'chat' &&
382
+ action.desired.usesDomain === false &&
383
+ step.destroys;
384
+ if (reverseReKey)
385
+ failed.add(stepField(action));
386
+ }
387
+ for (const { action } of detail.plannedSteps.slice(completed))
388
+ failed.add(stepField(action));
389
+ return [...failed];
390
+ }
154
391
  /** Order-independent structural equality between two plans. */
155
392
  function plansMatch(a, b) {
156
393
  return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
@@ -22,6 +22,13 @@ import type { CloudflareReader, CoolifyReader, LiveResource, ZitadelReader } fro
22
22
  *
23
23
  * `packages/foundry/package.json` had zero `@wtfalch/*` dependencies before
24
24
  * this file; `@wtfalch/providers` is its first.
25
+ *
26
+ * Each reader also matches a live provider resource back onto the foundry
27
+ * resource it was created for, via {@link RecordedIds} /
28
+ * {@link storeRecordedIds} below, so `computePlanDetail` (`diff.ts`) treats
29
+ * what foundry itself provisioned as matched instead of unmanaged (#10) --
30
+ * see the campaign design doc,
31
+ * `.claude/campaign/2026-09-24-keys-and-memberships.md`, "Design for (1)".
25
32
  */
26
33
  /**
27
34
  * Resolves an estate {@link OrgId} to one provider's own scope id — a
@@ -38,19 +45,44 @@ import type { CloudflareReader, CoolifyReader, LiveResource, ZitadelReader } fro
38
45
  */
39
46
  export type ScopeResolver = (orgId: OrgId) => Promise<string | null>;
40
47
  /**
41
- * A {@link ScopeResolver} backed by `AppliedState.serviceIds`
42
- * (`../state.ts`: "one id per service ... keyed by that service's own name
43
- * for the org") — the one place foundry records a provider's own id for an
44
- * org today, through the same `DeclaredOrgStore` seam #18 built.
48
+ * A {@link ScopeResolver} backed by `AppliedState.serviceIds` (`../state.ts`)
49
+ * — the one place foundry records a provider's own id for an org today,
50
+ * through the same `DeclaredOrgStore` seam #18 built.
45
51
  *
46
- * Nothing writes a `serviceIds[serviceKey]` entry yet — that is the
47
- * `StepExecutor` half of #20, not built here — so every resolver built this
48
- * way returns `null` for every org until it does. That is the correct
49
- * "nothing live yet" answer: a reader passed `null` returns `[]`, which
50
- * `computePlanDetail` (`diff.ts`) already reads as "no live resource",
51
- * exactly right for an org with no Coolify project (etc.) yet.
52
+ * `runSteps` (`plan-apply.ts`) now writes one `serviceIds` entry per
53
+ * resource it creates, keyed `system:kind:key` ({@link storeRecordedIds}
54
+ * below reads those back), and #20's `FoundryStepExecutor` (`executor.ts`)
55
+ * now also calls `StepExecutionContext.recordScopeId` for `coolify_project`,
56
+ * `zitadel_org` and `zitadel_project`, so a resolver built with one of those
57
+ * three `serviceKey`s returns the real id once that step has run. A
58
+ * Cloudflare zone is the one scope id nothing writes yet: `executor.ts`'s
59
+ * `cloudflareZone` records its result only under `cloudflare:zone:<domain>`
60
+ * (a resource id, read back by {@link storeRecordedIds}), not under a plain
61
+ * `cloudflare_zone` scope key — a resolver built with that `serviceKey`
62
+ * still returns `null` for every org. That is the correct "nothing live yet"
63
+ * answer: a reader passed `null` returns `[]`, which `computePlanDetail`
64
+ * (`diff.ts`) already reads as "no live resource".
52
65
  */
53
66
  export declare function storeScopeResolver(store: DeclaredOrgStore, serviceKey: string): ScopeResolver;
67
+ /**
68
+ * Resolves an estate {@link OrgId} to the provider ids this org's applied
69
+ * state has recorded for one reader's `system`, inverted so a reader can
70
+ * look a live resource's own id up directly: the provider's id -> the
71
+ * foundry `kind:key` it was recorded under.
72
+ *
73
+ * Backed by the same `AppliedState.serviceIds` {@link storeScopeResolver}
74
+ * reads, but a different slice of it: `plan-apply.ts`'s `runSteps` records a
75
+ * completed step's `resourceId` under `serviceIds[`${system}:${kind}:${key}`]`
76
+ * (`../state.ts`'s doc comment; design:
77
+ * `.claude/campaign/2026-09-24-keys-and-memberships.md`, "Design for (1)").
78
+ * Only entries in that three-part form, for this `system`, are kept — a
79
+ * scope id (`coolify_project`, etc.) has no `:` in it and is filtered out
80
+ * rather than mistaken for a resource id. `key` is rejoined with `:` rather
81
+ * than taken as a single segment because it is not always the org id: a
82
+ * declared ZITADEL app is keyed by its own name (#71), not `orgId`.
83
+ */
84
+ export type RecordedIds = (orgId: OrgId) => Promise<Record<string, string>>;
85
+ export declare function storeRecordedIds(store: DeclaredOrgStore, system: LiveResource['system']): RecordedIds;
54
86
  /**
55
87
  * Backed by `@wtfalch/providers/coolify`'s `CoolifyClient.listResources`: a
56
88
  * Coolify project's environments and in-scope applications (v1 scope — see
@@ -59,7 +91,8 @@ export declare function storeScopeResolver(store: DeclaredOrgStore, serviceKey:
59
91
  export declare class ProvidersCoolifyReader implements CoolifyReader {
60
92
  private readonly client;
61
93
  private readonly resolveScope;
62
- constructor(client: CoolifyClient, resolveScope: ScopeResolver);
94
+ private readonly resolveRecordedIds;
95
+ constructor(client: CoolifyClient, resolveScope: ScopeResolver, resolveRecordedIds: RecordedIds);
63
96
  listResources(orgId: OrgId): Promise<LiveResource[]>;
64
97
  }
65
98
  /**
@@ -70,7 +103,8 @@ export declare class ProvidersCoolifyReader implements CoolifyReader {
70
103
  export declare class ProvidersCloudflareReader implements CloudflareReader {
71
104
  private readonly client;
72
105
  private readonly resolveScope;
73
- constructor(client: CloudflareClient, resolveScope: ScopeResolver);
106
+ private readonly resolveRecordedIds;
107
+ constructor(client: CloudflareClient, resolveScope: ScopeResolver, resolveRecordedIds: RecordedIds);
74
108
  listResources(orgId: OrgId): Promise<LiveResource[]>;
75
109
  }
76
110
  /**
@@ -81,6 +115,7 @@ export declare class ProvidersCloudflareReader implements CloudflareReader {
81
115
  export declare class ProvidersZitadelReader implements ZitadelReader {
82
116
  private readonly client;
83
117
  private readonly resolveScope;
84
- constructor(client: ZitadelClient, resolveScope: ScopeResolver);
118
+ private readonly resolveRecordedIds;
119
+ constructor(client: ZitadelClient, resolveScope: ScopeResolver, resolveRecordedIds: RecordedIds);
85
120
  listResources(orgId: OrgId): Promise<LiveResource[]>;
86
121
  }