@patronage/factory-ci 1.0.0-alpha.32 → 1.0.0-alpha.34

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,4 +1,4 @@
1
- import { i as isLocalPreviewStage, n as executeAlchemyEntry, r as resolveAlchemyChildCwd, s as ALCHEMY_BASELINE } from "../execute-alchemy-entry-Dbna-8xq.js";
1
+ import { i as isLocalPreviewStage, n as executeAlchemyEntry, r as resolveAlchemyChildCwd, s as ALCHEMY_BASELINE } from "../execute-alchemy-entry-BZpk0eOQ.js";
2
2
  import { createRequire } from "node:module";
3
3
  import path from "node:path";
4
4
  import { createHash } from "node:crypto";
@@ -9,6 +9,8 @@ import { tryFindProviderByType } from "alchemy/Provider";
9
9
  import * as Effect from "effect/Effect";
10
10
  import * as Layer from "effect/Layer";
11
11
  import * as Option from "effect/Option";
12
+ import { State } from "alchemy/State";
13
+ import { allStages, deleteState } from "alchemy/State/Tree";
12
14
  import { isDeepStrictEqual } from "node:util";
13
15
  //#region src/alchemy/evaluate-stack.ts
14
16
  var EvaluationProviders = class extends Alchemy.ProviderCollection()("factory-ci/evaluate-stack") {};
@@ -90,15 +92,14 @@ var RetainedIdentityError = class extends Error {
90
92
  }
91
93
  };
92
94
  /**
93
- * Read `alchemy state export` output into a snapshot of one stage.
95
+ * Read a persisted-state document into a snapshot of one stage.
94
96
  *
95
- * `alchemy state export` prints one document holding every record in scope
96
- * (`{ resources: [{ stack, stage, fqn, state }] }`); `alchemy state get`
97
- * prints a single record and is not this shape. Run without `--stack` the
98
- * command exports the whole estate, so one fqn appears once per stage and a
99
- * marker naming it would match whichever record sorted first — a `prod`
100
- * identity asserted against a `dev` record. `scope` narrows the document to
101
- * the stack and stage the markers are about.
97
+ * The persisted-state document holds every record in scope
98
+ * (`{ resources: [{ stack, stage, fqn, state }] }`); a single-record document
99
+ * is not this shape. A document covering the whole estate holds one fqn once
100
+ * per stage, so a marker naming it would match whichever record sorted
101
+ * first — a `prod` identity asserted against a `dev` record. `scope` narrows
102
+ * the document to the stack and stage the markers are about.
102
103
  *
103
104
  * Throws on anything that is not a resource inventory, and on a scoped
104
105
  * document that still records one fqn twice. A state this module cannot read
@@ -162,113 +163,6 @@ const assertRetainedIdentities = ({ after, before, markers }) => {
162
163
  if (violations.length > 0) throw new RetainedIdentityError(violations);
163
164
  };
164
165
  //#endregion
165
- //#region src/alchemy/stage-policy.ts
166
- /**
167
- * Stage and stack pairing, and protected-stage policy, from a project-supplied
168
- * table (#991).
169
- *
170
- * Two projects wrote the same three questions twice — is this stage protected,
171
- * is it disposable, and may this stack run it — and each answered them with its
172
- * own stage names hard-coded into the answer. The names differ; the questions
173
- * do not. This module owns the questions. A project supplies the table and gets
174
- * decisions back, so no project, stack, or stage name appears here.
175
- *
176
- * The disposable grammar this package already owns (`disposable-stage.ts`) is
177
- * available as a matcher rather than assumed, because a project's disposable
178
- * set is wider than that one grammar.
179
- */
180
- /** Every stage the shared disposable grammar recognises. */
181
- const LOCAL_PREVIEW_STAGES = (stage) => isLocalPreviewStage(stage);
182
- /**
183
- * Test a pattern without carrying state between calls.
184
- *
185
- * A `RegExp` with `g` or `y` advances `lastIndex` on every match, so the same
186
- * pattern answers "yes" and then "no" for the same stage. A policy that
187
- * alternates is worse than one that always refuses, so a flagged pattern is
188
- * tested through a fresh copy whose `lastIndex` starts at zero. The copy
189
- * carries the complete flags: `y` anchors the match at `lastIndex`, so
190
- * dropping it would let `/prod/y` accept a stage named `pre-prod` that the
191
- * project's own pattern rejects. The caller's own `RegExp` is never written
192
- * to, so a frozen one still works.
193
- */
194
- const statelessTest$1 = (pattern, stage) => pattern.global || pattern.sticky ? new RegExp(pattern.source, pattern.flags).test(stage) : pattern.test(stage);
195
- const stageMatches = (matcher, stage) => {
196
- if (typeof matcher === "function") return matcher(stage);
197
- if (Array.isArray(matcher)) return matcher.includes(stage);
198
- return statelessTest$1(matcher, stage);
199
- };
200
- /** A matcher that accepts a stage any of `matchers` accepts. */
201
- const anyStage = (...matchers) => (stage) => matchers.some((matcher) => stageMatches(matcher, stage));
202
- /**
203
- * A policy refusal. The `code` is the stable part: a project maps it onto its
204
- * own lifecycle failure envelope, and the message is for a human.
205
- */
206
- var StagePolicyError = class extends Error {
207
- code;
208
- constructor(code, message) {
209
- super(message);
210
- this.name = "StagePolicyError";
211
- this.code = code;
212
- }
213
- };
214
- const assertTable = (table) => {
215
- const seen = /* @__PURE__ */ new Set();
216
- for (const entry of table.stacks ?? []) {
217
- if (!entry.stack) throw new TypeError("Every stack ownership entry needs a stack name.");
218
- if (seen.has(entry.stack)) throw new TypeError(`Stack "${entry.stack}" appears twice in the stack registry.`);
219
- seen.add(entry.stack);
220
- }
221
- };
222
- /**
223
- * Read a project's stage table and answer the stage questions from it.
224
- *
225
- * A stage the table classifies as both protected and disposable is a table
226
- * defect, not a stage a caller may act on, so every query on it throws.
227
- */
228
- const stagePolicy = (table) => {
229
- assertTable(table);
230
- const classify = (stage) => {
231
- const isProtected = stageMatches(table.stages.protected, stage);
232
- const isDisposable = stageMatches(table.stages.disposable, stage);
233
- if (isProtected && isDisposable) throw new StagePolicyError("AMBIGUOUS_STAGE", `Stage "${stage}" is declared both protected and disposable.`);
234
- return {
235
- isDisposable,
236
- isProtected
237
- };
238
- };
239
- const isProtectedStage = (stage) => classify(stage).isProtected;
240
- const isDisposableStage = (stage) => classify(stage).isDisposable;
241
- const isKnownStage = (stage) => {
242
- const { isDisposable, isProtected } = classify(stage);
243
- return isProtected || isDisposable;
244
- };
245
- const assertKnownStage = (stage) => {
246
- if (!isKnownStage(stage)) throw new StagePolicyError("UNKNOWN_STAGE", `Unsupported Alchemy stage: ${stage}`);
247
- };
248
- const assertDestructiveStage = (stage) => {
249
- assertKnownStage(stage);
250
- if (isProtectedStage(stage)) throw new StagePolicyError("PROTECTED_STAGE", `Refusing destructive operation for protected stage: ${stage}`);
251
- };
252
- const assertStackOwnsStage = (stack, stage) => {
253
- assertKnownStage(stage);
254
- const registry = table.stacks;
255
- if (registry === void 0) return;
256
- const entry = registry.find((candidate) => candidate.stack === stack);
257
- if (entry === void 0) throw new StagePolicyError("UNKNOWN_STACK", `Unsupported Alchemy stack: ${stack}`);
258
- const owner = registry.find((candidate) => candidate.stack !== stack && candidate.claims !== void 0 && stageMatches(candidate.claims, stage));
259
- if (owner !== void 0) throw new StagePolicyError("STAGE_NOT_OWNED", `Stage ${stage} belongs only to stack ${owner.stack}, not ${stack}.`);
260
- if (entry.stages !== void 0 && !stageMatches(entry.stages, stage)) throw new StagePolicyError("STAGE_NOT_OWNED", `Stack ${stack} does not run stage ${stage}.`);
261
- };
262
- return {
263
- assertDestructiveStage,
264
- assertKnownStage,
265
- assertStackOwnsStage,
266
- isDisposableStage,
267
- isKnownStage,
268
- isProtectedStage
269
- };
270
- };
271
- //#endregion
272
166
  //#region src/alchemy/credential-preflight.ts
273
167
  /**
274
168
  * Cloudflare credential preflight for an Alchemy deploy.
@@ -437,6 +331,201 @@ const credentialPreflight = async ({ accountId, environment, fetchImplementation
437
331
  };
438
332
  };
439
333
  //#endregion
334
+ //#region src/alchemy/stage-policy.ts
335
+ /**
336
+ * Stage and stack pairing, and protected-stage policy, from a project-supplied
337
+ * table (#991).
338
+ *
339
+ * Two projects wrote the same three questions twice — is this stage protected,
340
+ * is it disposable, and may this stack run it — and each answered them with its
341
+ * own stage names hard-coded into the answer. The names differ; the questions
342
+ * do not. This module owns the questions. A project supplies the table and gets
343
+ * decisions back, so no project or stack name appears here.
344
+ *
345
+ * One stage name does appear: `local`. It names the local emulator rather than
346
+ * any project's estate, every project spells it the same way, and the question
347
+ * it answers — is this the emulated stage — has one answer for the whole fleet.
348
+ * See {@link isLocalEmulationStage}.
349
+ *
350
+ * The disposable grammar this package already owns (`disposable-stage.ts`) is
351
+ * available as a matcher rather than assumed, because a project's disposable
352
+ * set is wider than that one grammar.
353
+ */
354
+ /** Every stage the shared disposable grammar recognises. */
355
+ const LOCAL_PREVIEW_STAGES = (stage) => isLocalPreviewStage(stage);
356
+ /**
357
+ * Test a pattern without carrying state between calls.
358
+ *
359
+ * A `RegExp` with `g` or `y` advances `lastIndex` on every match, so the same
360
+ * pattern answers "yes" and then "no" for the same stage. A policy that
361
+ * alternates is worse than one that always refuses, so a flagged pattern is
362
+ * tested through a fresh copy whose `lastIndex` starts at zero. The copy
363
+ * carries the complete flags: `y` anchors the match at `lastIndex`, so
364
+ * dropping it would let `/prod/y` accept a stage named `pre-prod` that the
365
+ * project's own pattern rejects. The caller's own `RegExp` is never written
366
+ * to, so a frozen one still works.
367
+ */
368
+ const statelessTest$1 = (pattern, stage) => pattern.global || pattern.sticky ? new RegExp(pattern.source, pattern.flags).test(stage) : pattern.test(stage);
369
+ const stageMatches = (matcher, stage) => {
370
+ if (typeof matcher === "function") return matcher(stage);
371
+ if (Array.isArray(matcher)) return matcher.includes(stage);
372
+ return statelessTest$1(matcher, stage);
373
+ };
374
+ /** A matcher that accepts a stage any of `matchers` accepts. */
375
+ const anyStage = (...matchers) => (stage) => matchers.some((matcher) => stageMatches(matcher, stage));
376
+ /**
377
+ * A policy refusal. The `code` is the stable part: a project maps it onto its
378
+ * own lifecycle failure envelope, and the message is for a human.
379
+ */
380
+ var StagePolicyError = class extends Error {
381
+ code;
382
+ constructor(code, message) {
383
+ super(message);
384
+ this.name = "StagePolicyError";
385
+ this.code = code;
386
+ }
387
+ };
388
+ const assertTable = (table) => {
389
+ const seen = /* @__PURE__ */ new Set();
390
+ for (const entry of table.stacks ?? []) {
391
+ if (!entry.stack) throw new TypeError("Every stack ownership entry needs a stack name.");
392
+ if (seen.has(entry.stack)) throw new TypeError(`Stack "${entry.stack}" appears twice in the stack registry.`);
393
+ seen.add(entry.stack);
394
+ }
395
+ };
396
+ /**
397
+ * Is this the stage that runs against the local emulator?
398
+ *
399
+ * True for the literal `local` and nothing else (#991). Two projects and HQ
400
+ * each answered this with their own rule — a refusal list, a constant, and
401
+ * `$(whoami)` — and all three meant the same single name, so the answer is one
402
+ * literal rather than a pattern or a table. A disposable preview stage such as
403
+ * `local-pr-1-abcdef0` shares the prefix but runs against a real account, so it
404
+ * is false here. The comparison is exact: no trim and no case folding, because
405
+ * a stage name reaches Alchemy exactly as it is spelled.
406
+ *
407
+ * This question is deliberately outside {@link stagePolicy}: a project's table
408
+ * classifies the stages of its own estate, and the emulated stage belongs to
409
+ * no estate.
410
+ */
411
+ const isLocalEmulationStage = (stage) => stage === "local";
412
+ /**
413
+ * Read a project's stage table and answer the stage questions from it.
414
+ *
415
+ * A stage the table classifies as both protected and disposable is a table
416
+ * defect, not a stage a caller may act on, so every query on it throws.
417
+ */
418
+ const stagePolicy = (table) => {
419
+ assertTable(table);
420
+ const classify = (stage) => {
421
+ const isProtected = stageMatches(table.stages.protected, stage);
422
+ const isDisposable = stageMatches(table.stages.disposable, stage);
423
+ if (isProtected && isDisposable) throw new StagePolicyError("AMBIGUOUS_STAGE", `Stage "${stage}" is declared both protected and disposable.`);
424
+ return {
425
+ isDisposable,
426
+ isProtected
427
+ };
428
+ };
429
+ const isProtectedStage = (stage) => classify(stage).isProtected;
430
+ const isDisposableStage = (stage) => classify(stage).isDisposable;
431
+ const isKnownStage = (stage) => {
432
+ const { isDisposable, isProtected } = classify(stage);
433
+ return isProtected || isDisposable;
434
+ };
435
+ const assertKnownStage = (stage) => {
436
+ if (!isKnownStage(stage)) throw new StagePolicyError("UNKNOWN_STAGE", `Unsupported Alchemy stage: ${stage}`);
437
+ };
438
+ const assertDestructiveStage = (stage) => {
439
+ assertKnownStage(stage);
440
+ if (isProtectedStage(stage)) throw new StagePolicyError("PROTECTED_STAGE", `Refusing destructive operation for protected stage: ${stage}`);
441
+ };
442
+ const assertStackOwnsStage = (stack, stage) => {
443
+ assertKnownStage(stage);
444
+ const registry = table.stacks;
445
+ if (registry === void 0) return;
446
+ const entry = registry.find((candidate) => candidate.stack === stack);
447
+ if (entry === void 0) throw new StagePolicyError("UNKNOWN_STACK", `Unsupported Alchemy stack: ${stack}`);
448
+ const owner = registry.find((candidate) => candidate.stack !== stack && candidate.claims !== void 0 && stageMatches(candidate.claims, stage));
449
+ if (owner !== void 0) throw new StagePolicyError("STAGE_NOT_OWNED", `Stage ${stage} belongs only to stack ${owner.stack}, not ${stack}.`);
450
+ if (entry.stages !== void 0 && !stageMatches(entry.stages, stage)) throw new StagePolicyError("STAGE_NOT_OWNED", `Stack ${stack} does not run stage ${stage}.`);
451
+ };
452
+ return {
453
+ assertDestructiveStage,
454
+ assertKnownStage,
455
+ assertStackOwnsStage,
456
+ isDisposableStage,
457
+ isKnownStage,
458
+ isProtectedStage
459
+ };
460
+ };
461
+ //#endregion
462
+ //#region src/alchemy/local-emulation.ts
463
+ /**
464
+ * The child environment a local-emulation Alchemy run needs (#1168).
465
+ *
466
+ * Alchemy refuses to start when `CLOUDFLARE_ACCOUNT_ID` is missing or
467
+ * malformed, even for a run that only drives the local emulator and calls no
468
+ * Cloudflare API. Every project worked around that by handing the child a real
469
+ * account id and a real token, so a purely local run carried live credentials.
470
+ * This module hands it a sentinel pair instead: a well-formed account id that
471
+ * addresses no account, and a token that is not a token.
472
+ *
473
+ * The helper never reads a real credential. It reads one variable — the
474
+ * live-binding opt-in — and nothing else. The presence of a real credential in
475
+ * the parent environment is never the signal: a developer who is logged in to
476
+ * Cloudflare still gets the sentinel pair unless the opt-in says otherwise.
477
+ *
478
+ * Scrubbing an ambient token out of the child is the launcher's job, not this
479
+ * module's. This module owns the environment shape only.
480
+ */
481
+ const [ACCOUNT_ID_NAME, API_TOKEN_NAME] = HOSTED_CLOUDFLARE_CREDENTIAL_NAMES;
482
+ /**
483
+ * A well-formed Cloudflare account id that addresses no account.
484
+ *
485
+ * Alchemy validates the value against `/^[0-9a-f]{32}$/i` and rejects
486
+ * placeholders such as `""` or `dummy` with an authentication error before any
487
+ * run starts. The check is `validateAccountId`, in
488
+ * `alchemy/lib/Cloudflare/Auth/AuthProvider.js` at 2.0.0-beta.76 and in
489
+ * `alchemy/lib/Cloudflare/Auth/AuthConfig.js` at 2.0.0-beta.77. The file name
490
+ * moves; the pattern is the same in both. Thirty-two zeros pass that shape
491
+ * check and route to nothing.
492
+ */
493
+ const SENTINEL_ACCOUNT_ID = "0".repeat(32);
494
+ /**
495
+ * A token that is not a token. It is spelled as words, so no secret scanner
496
+ * reads it as a credential and no reader mistakes it for one. Any API call
497
+ * made with it fails, which is the intended outcome: a local-emulation run
498
+ * must reach no Cloudflare account.
499
+ */
500
+ const SENTINEL_API_TOKEN = "factory-ci-local-emulation-no-cloudflare-token";
501
+ /**
502
+ * The one way a real credential reaches a local-emulation child. Set it to a
503
+ * non-empty value and this helper returns no sentinel, so whatever
504
+ * `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` the parent carries pass
505
+ * through untouched and the run may bind a live Cloudflare resource.
506
+ */
507
+ const LIVE_BINDING_OPT_IN = "FACTORY_CI_LIVE_CLOUDFLARE_BINDING";
508
+ /**
509
+ * Build the Cloudflare variables a local-emulation child is launched with.
510
+ *
511
+ * Returns exactly two variables for the local-emulation stage, or no variables
512
+ * when the live-binding opt-in is set. The result is an overlay: merge it over
513
+ * the child environment the launcher has otherwise assembled.
514
+ *
515
+ * Throws for any other stage. A non-local stage deploys to a real account, so
516
+ * a sentinel there would break that deploy, and returning nothing instead
517
+ * would let a caller believe a local run was protected when it was not.
518
+ */
519
+ const localEmulationEnvironment = (options) => {
520
+ const { environment = process.env, stage } = options;
521
+ if (!isLocalEmulationStage(stage)) throw new Error(`localEmulationEnvironment is only for the local-emulation stage, not "${stage}".`);
522
+ if ((environment[LIVE_BINDING_OPT_IN] ?? "").trim().length > 0) return {};
523
+ return {
524
+ [ACCOUNT_ID_NAME]: SENTINEL_ACCOUNT_ID,
525
+ [API_TOKEN_NAME]: SENTINEL_API_TOKEN
526
+ };
527
+ };
528
+ //#endregion
440
529
  //#region src/alchemy/destroy-postcondition.ts
441
530
  /**
442
531
  * Read the state store through the project's reader.
@@ -475,6 +564,370 @@ const assertDestroyLeftNothing = async ({ stack, stage, stateReader }) => {
475
564
  if (remaining.length > 0) throw new Error(`Alchemy destroy left ${remaining.length} resource registered for ${stack} in ${stage}: ${remaining.join(", ")}`);
476
565
  };
477
566
  //#endregion
567
+ //#region src/alchemy/plan-effects.ts
568
+ /**
569
+ * The plan-output adapter (#1077): the one reader of what the installed
570
+ * Alchemy CLI prints for a plan.
571
+ *
572
+ * Alchemy has no structured plan output at the fleet baseline, so the plan
573
+ * is read from the lines `formatPlanLines` in `alchemy/src/Cli/LoggingCli.ts`
574
+ * prints: one `Plan:` summary, one `[fqn] action` line per resource, one
575
+ * `[fqn/name] action` line per binding of that resource, and one
576
+ * `[fqn] run|drop [action]` line per task. Paitronage and HQ each wrote this
577
+ * reader; this is the one copy, and it is bound to the Alchemy version whose
578
+ * renderer it reads. A consumer running a different Alchemy is refused by
579
+ * `runAlchemyLifecycle` before any child runs, so a renderer change cannot
580
+ * be read as a plan with different effects.
581
+ *
582
+ * The renderer logs every line through Effect's pretty console logger, so a
583
+ * line arrives as `[HH:MM:SS.mmm] INFO (#1): <line>`, and coloured when the
584
+ * child inherits `FORCE_COLOR`. Both wrappers are stripped before a line is
585
+ * read. The captured output under `tests/fixtures/plan-output/beta-77/` is
586
+ * the evidence for every shape this file reads.
587
+ *
588
+ * The adapter fails on anything it does not recognise: no summary, two
589
+ * summaries, an action outside the known set, a summary it cannot parse, a
590
+ * summary whose counts do not equal the lines under it, or rows the summary
591
+ * can place in more than one way. An effect it could not account for is
592
+ * never reported as zero.
593
+ *
594
+ * Two readings share that work. `parsePlanEffects` answers what the plan
595
+ * does, which is what the lifecycle's admission rules read. `canonicalPlan`
596
+ * answers what the plan *is*, as a stable string: the rows the renderer
597
+ * printed and the reading of them, with the per-run logger metadata it also
598
+ * prints left out. `firstDeployPlanHash` hashes that string, so a plan
599
+ * reviewed at one moment and deployed at another is the same plan (#1232).
600
+ */
601
+ /** The Alchemy version whose plan renderer this adapter reads. */
602
+ const PLAN_OUTPUT_ALCHEMY_VERSION = ALCHEMY_BASELINE.alchemy;
603
+ /**
604
+ * Every action a resource row can carry. The first six are the actions the
605
+ * summary counts, in the order the renderer prints them; `noop` rows print
606
+ * under the summary but are never counted in it.
607
+ */
608
+ const PLAN_ACTIONS = [
609
+ "create",
610
+ "update",
611
+ "adopted",
612
+ "replace",
613
+ "delete",
614
+ "orphaned",
615
+ "noop"
616
+ ];
617
+ /** The actions the summary counts. */
618
+ const SUMMARY_ACTIONS = PLAN_ACTIONS.filter((action) => action !== "noop");
619
+ /** The adapter could not read the output. `reason` is the whole detail. */
620
+ var PlanEffectsError = class extends Error {
621
+ reason;
622
+ constructor(reason) {
623
+ super(`Alchemy plan output was not readable: ${reason}.`);
624
+ this.name = "PlanEffectsError";
625
+ this.reason = reason;
626
+ }
627
+ };
628
+ const ACTION_SET = new Set(PLAN_ACTIONS);
629
+ const BINDING_ACTIONS = new Set([
630
+ "create",
631
+ "noop",
632
+ "unbind",
633
+ "update"
634
+ ]);
635
+ const TASK_ACTIONS = new Set(["drop", "run"]);
636
+ const ansiPattern = new RegExp(`${String.fromCodePoint(27)}\\[[0-?]*[ -/]*[@-~]`, "gu");
637
+ /**
638
+ * The prefix Effect's pretty console logger puts before every line the
639
+ * renderer logs. Optional, so output that was not logged through it (an
640
+ * apply session's stack output, a scripted CLI in a test) reads the same.
641
+ */
642
+ const EFFECT_LOG_PREFIX = /^\[\d{2}:\d{2}:\d{2}\.\d{3}\] (?:ALL|TRACE|DEBUG|INFO|WARN|ERROR|FATAL|NONE) \(#\d+\)(?: [^:]+)?:(?: |$)/u;
643
+ const ROW_PATTERN = /^\[(?<tag>[^\]]+)\]\s+(?<action>\S+)/u;
644
+ const TASK_ROW_PATTERN = /^\[(?<tag>[^\]]+)\]\s+(?<action>\S+)\s+\[action\]$/u;
645
+ const SUMMARY_PREFIX = "Plan:";
646
+ /** Every resource row is `noop`; there may be rows. */
647
+ const NO_CHANGES = "Plan: no changes";
648
+ /** The stack declares nothing and persists nothing; there are no rows. */
649
+ const NO_RESOURCES = "Plan: no resources";
650
+ const SUMMARY_ACTION_ITEM = /^(?<count>\d+) to (?<action>create|update|adopted|replace|delete|orphaned)$/u;
651
+ const SUMMARY_BINDINGS_ITEM = /^(?<count>\d+) binding changes$/u;
652
+ const SUMMARY_TASKS_ITEM = /^(?<count>\d+) tasks$/u;
653
+ const zeroCounts = () => ({
654
+ adopted: 0,
655
+ create: 0,
656
+ delete: 0,
657
+ noop: 0,
658
+ orphaned: 0,
659
+ replace: 0,
660
+ update: 0
661
+ });
662
+ const isPlanAction = (action) => ACTION_SET.has(action);
663
+ const isTaskAction = (action) => TASK_ACTIONS.has(action);
664
+ /**
665
+ * The rows under the summary, up to the first line that is not a row. An
666
+ * apply session prints its progress after a blank line, so that line ends
667
+ * the plan. A task row is told apart by its `[action]` suffix; a resource
668
+ * row and a binding row are told apart by `readRows` below.
669
+ */
670
+ const rowsUnder = (lines, summaryIndex) => {
671
+ const rows = [];
672
+ const tasks = [];
673
+ for (const line of lines.slice(summaryIndex + 1)) {
674
+ const task = TASK_ROW_PATTERN.exec(line)?.groups;
675
+ if (task?.tag !== void 0 && task.action !== void 0) {
676
+ if (!isTaskAction(task.action)) throw new PlanEffectsError("unknown-action");
677
+ tasks.push({
678
+ action: task.action,
679
+ id: task.tag
680
+ });
681
+ continue;
682
+ }
683
+ const row = ROW_PATTERN.exec(line)?.groups;
684
+ if (row?.tag === void 0 || row.action === void 0) break;
685
+ if (!(isPlanAction(row.action) || BINDING_ACTIONS.has(row.action))) throw new PlanEffectsError("unknown-action");
686
+ rows.push({
687
+ action: row.action,
688
+ tag: row.tag
689
+ });
690
+ }
691
+ return {
692
+ rows,
693
+ tasks
694
+ };
695
+ };
696
+ /**
697
+ * The one reading of the rows as resources and bindings whose counts equal
698
+ * the summary. No such reading is a count mismatch; two are refused as
699
+ * ambiguous rather than guessed.
700
+ *
701
+ * A binding row is tagged `<resource fqn>/<name>` and printed directly under
702
+ * its resource, so it always extends the FQN of the last resource row. A
703
+ * resource inside a namespace is tagged the same way, and when it follows a
704
+ * resource whose FQN is its namespace path the two rows look alike:
705
+ * `prefix/plan.txt` under the fixtures prints `[Parent/bound] create` and
706
+ * `[Parent/Zed] create` under `[Parent] create`. The renderer gives no other
707
+ * evidence, so a row that extends the last resource's FQN is read both ways
708
+ * and the summary decides: it counts resources per action and the binding
709
+ * rows with a change, and only a reading that adds up to it survives.
710
+ *
711
+ * A `noop` row is counted by neither, so the summary cannot place it. Such a
712
+ * row extending the last resource's FQN is read as a binding: a `noop` row
713
+ * drives no decision (`runAlchemyLifecycle` reads only rows with work), and
714
+ * a plan's bindings are far more often `noop` than its namespaced resources
715
+ * are named after a sibling. `prefix/noop.txt` is that residual.
716
+ *
717
+ * The search counts readings, stopping at two, and is memoised on what the
718
+ * rest of the rows can depend on: the position, the last resource's FQN,
719
+ * and the `create` and `update` resources read so far (every other counted
720
+ * row is a resource, `unbind` is a binding, and `noop` is placed by rule, so
721
+ * those tallies follow from the position). A plan with many resources each
722
+ * followed by rows that extend its FQN then costs the product of the row
723
+ * count and the two tallies, not the product of the readings of each group.
724
+ * The first reading found is kept; every branch is on a row with work, so a
725
+ * second reading always places a row with work differently.
726
+ */
727
+ const readRows = (rows, stated) => {
728
+ const seen = zeroCounts();
729
+ const resources = [];
730
+ let bindingChanges = 0;
731
+ let first;
732
+ const memo = /* @__PURE__ */ new Map();
733
+ const visit = (index, owner) => {
734
+ const row = rows[index];
735
+ if (row === void 0) {
736
+ if (bindingChanges !== stated.bindingChanges || SUMMARY_ACTIONS.some((action) => seen[action] !== stated.counts[action])) return 0;
737
+ first ??= [...resources];
738
+ return 1;
739
+ }
740
+ const key = JSON.stringify([
741
+ index,
742
+ owner,
743
+ seen.create,
744
+ seen.update
745
+ ]);
746
+ const known = memo.get(key);
747
+ if (known !== void 0) return known;
748
+ let readings = 0;
749
+ const { action, tag } = row;
750
+ if (owner !== void 0 && tag.startsWith(`${owner}/`) && BINDING_ACTIONS.has(action)) {
751
+ if (action === "noop") return visit(index + 1, owner);
752
+ bindingChanges += 1;
753
+ if (bindingChanges <= stated.bindingChanges) readings += visit(index + 1, owner);
754
+ bindingChanges -= 1;
755
+ }
756
+ if (readings < 2 && isPlanAction(action)) {
757
+ seen[action] += 1;
758
+ resources.push({
759
+ action,
760
+ id: tag
761
+ });
762
+ if (action === "noop" || seen[action] <= stated.counts[action]) readings += visit(index + 1, tag);
763
+ resources.pop();
764
+ seen[action] -= 1;
765
+ }
766
+ const capped = Math.min(readings, 2);
767
+ memo.set(key, capped);
768
+ return capped;
769
+ };
770
+ const readings = visit(0);
771
+ if (first === void 0 || readings === 0) throw new PlanEffectsError("count-mismatch");
772
+ if (readings > 1) throw new PlanEffectsError("ambiguous-rows");
773
+ return {
774
+ bindingChanges: stated.bindingChanges,
775
+ resources: first
776
+ };
777
+ };
778
+ /**
779
+ * The counts the summary states, in the order Alchemy prints them: the six
780
+ * actions, then `N binding changes`, then `N tasks`, each only when non-zero.
781
+ * The summary is rebuilt from what was read and must equal the line, so a
782
+ * summary with an extra clause, a repeated part, or a different order is
783
+ * refused instead of partially read.
784
+ */
785
+ const countsStated = (summary) => {
786
+ const counts = zeroCounts();
787
+ let bindingChanges = 0;
788
+ let tasks = 0;
789
+ for (const item of summary.slice(5).trim().split(", ")) {
790
+ const action = SUMMARY_ACTION_ITEM.exec(item)?.groups;
791
+ const bindings = SUMMARY_BINDINGS_ITEM.exec(item)?.groups;
792
+ const taskCount = SUMMARY_TASKS_ITEM.exec(item)?.groups;
793
+ if (action?.action && isPlanAction(action.action)) counts[action.action] = Number(action.count);
794
+ else if (bindings) bindingChanges = Number(bindings.count);
795
+ else if (taskCount) tasks = Number(taskCount.count);
796
+ else throw new PlanEffectsError("summary-unrecognized");
797
+ }
798
+ const canonical = [
799
+ ...SUMMARY_ACTIONS.filter((action) => counts[action] > 0).map((action) => `${counts[action]} to ${action}`),
800
+ ...bindingChanges > 0 ? [`${bindingChanges} binding changes`] : [],
801
+ ...tasks > 0 ? [`${tasks} tasks`] : []
802
+ ].join(", ");
803
+ if (canonical === "" || `${SUMMARY_PREFIX} ${canonical}` !== summary) throw new PlanEffectsError("summary-unrecognized");
804
+ return {
805
+ bindingChanges,
806
+ counts,
807
+ tasks
808
+ };
809
+ };
810
+ /**
811
+ * Split the output into the one summary line and the rows under it. This is
812
+ * the whole of what the renderer's output is read as; `planEffects` below
813
+ * interprets it, and `canonicalPlan` serialises it.
814
+ */
815
+ const planLines = (output) => {
816
+ const lines = output.replaceAll(ansiPattern, "").split("\n").map((line) => line.replace(EFFECT_LOG_PREFIX, "").trim());
817
+ const summaryIndexes = lines.flatMap((line, index) => line.startsWith(SUMMARY_PREFIX) ? [index] : []);
818
+ if (summaryIndexes.length === 0) throw new PlanEffectsError("no-summary");
819
+ if (summaryIndexes.length > 1) throw new PlanEffectsError("multiple-summaries");
820
+ const summaryIndex = summaryIndexes[0] ?? 0;
821
+ return {
822
+ summary: lines[summaryIndex] ?? "",
823
+ ...rowsUnder(lines, summaryIndex)
824
+ };
825
+ };
826
+ /** What the summary and the rows under it say the plan does. */
827
+ const planEffects = ({ rows, summary, tasks }) => {
828
+ if (summary === NO_RESOURCES) {
829
+ if (rows.length > 0 || tasks.length > 0) throw new PlanEffectsError("effects-under-no-changes");
830
+ return {
831
+ bindingChanges: 0,
832
+ counts: zeroCounts(),
833
+ resources: [],
834
+ tasks: []
835
+ };
836
+ }
837
+ const stated = summary === NO_CHANGES ? {
838
+ bindingChanges: 0,
839
+ counts: zeroCounts(),
840
+ tasks: 0
841
+ } : countsStated(summary);
842
+ if (tasks.length !== stated.tasks) throw new PlanEffectsError("count-mismatch");
843
+ const { bindingChanges, resources } = readRows(rows, stated);
844
+ const counts = zeroCounts();
845
+ for (const { action } of resources) counts[action] += 1;
846
+ return {
847
+ bindingChanges,
848
+ counts,
849
+ resources,
850
+ tasks
851
+ };
852
+ };
853
+ /**
854
+ * Read one plan from Alchemy CLI output.
855
+ *
856
+ * Throws `PlanEffectsError` on output the adapter cannot account for. The
857
+ * output itself never appears in the error.
858
+ */
859
+ const parsePlanEffects = (output) => planEffects(planLines(output));
860
+ const compareText = (left, right) => {
861
+ if (left < right) return -1;
862
+ return left > right ? 1 : 0;
863
+ };
864
+ const compareCanonicalRows = (left, right) => compareText(left.kind, right.kind) || compareText(left.tag, right.tag) || compareText(left.action, right.action);
865
+ const compareResources = (left, right) => compareText(left.id, right.id) || compareText(left.action, right.action);
866
+ /**
867
+ * The plan as a stable string: what the renderer said about the plan, with
868
+ * what it said about the run that printed it left out.
869
+ *
870
+ * A plan carries no resource properties — the renderer prints a summary and
871
+ * one `[tag] action` row per resource, binding and task, and nothing else.
872
+ * So the canonical form is the summary line, every row under it as
873
+ * `{ action, kind, tag }`, and the reading of those rows `parsePlanEffects`
874
+ * arrives at (`resources` and `bindingChanges`). What is left out is the
875
+ * logger's timestamps and fiber ids, the `Plan ready (2.9s)` duration, ANSI
876
+ * colour, and everything after the rows: per-run facts about the process,
877
+ * not about the plan, and hashing them is why a reviewed plan could never be
878
+ * deployed (#1232).
879
+ *
880
+ * The rows and the reading each say something the other does not, so both
881
+ * are kept:
882
+ *
883
+ * - Every row is kept, including binding rows and `noop` rows, because
884
+ * `PlanEffects` reduces bindings to a count. A binding renamed with the
885
+ * count unchanged is one `PlanEffects` value and two row multisets.
886
+ * - The reading is kept because the rows alone do not say which of them are
887
+ * resources. A binding row and a namespaced resource row print alike —
888
+ * `[Parent/bound] create` and `[Parent/Zed] create` under
889
+ * `[Parent] create` — and only print order and the summary's counts tell
890
+ * them apart. Sorting the rows drops the order, so swapping those two rows
891
+ * is two plans creating different resources over one row multiset. The
892
+ * resource list the summary forces is what separates them.
893
+ *
894
+ * Together they pin the bindings too: the rows are the whole multiset and
895
+ * the resources are a sub-multiset of it, so the bindings are what remains.
896
+ *
897
+ * Rows and resources are both sorted, keeping duplicates, because print
898
+ * order is the renderer's traversal and authorizes nothing on its own: a
899
+ * plan that printed the same resources and bindings in another order must
900
+ * not read as a different plan.
901
+ *
902
+ * The full `parsePlanEffects` validation runs first, so anything the adapter
903
+ * refuses is refused here too (`PlanEffectsError`). Output it cannot account
904
+ * for is never canonicalised, and so is never authorized. `rowsUnder` stops
905
+ * at the first line that is not a row, so a log line interleaved into the
906
+ * rows on a chattier run drops the rows after it: an uncounted (`noop`) row
907
+ * lost that way changes the hash, and a row with work lost that way fails
908
+ * the summary's counts (`count-mismatch`). Either way the run is refused
909
+ * rather than admitted on less than the plan.
910
+ */
911
+ const canonicalPlan = (output) => {
912
+ const lines = planLines(output);
913
+ const { bindingChanges, resources } = planEffects(lines);
914
+ const rows = [...lines.rows.map(({ action, tag }) => ({
915
+ action,
916
+ kind: "row",
917
+ tag
918
+ })), ...lines.tasks.map(({ action, id }) => ({
919
+ action,
920
+ kind: "task",
921
+ tag: id
922
+ }))].toSorted(compareCanonicalRows);
923
+ return JSON.stringify({
924
+ bindingChanges,
925
+ resources: resources.toSorted(compareResources),
926
+ rows,
927
+ summary: lines.summary
928
+ });
929
+ };
930
+ //#endregion
478
931
  //#region src/alchemy/first-deploy-authorization.ts
479
932
  /**
480
933
  * First-deploy authorization by plan hash.
@@ -491,16 +944,51 @@ const assertDestroyLeftNothing = async ({ stack, stage, stateReader }) => {
491
944
  * with the project. This function answers one question: does this exact plan
492
945
  * appear on the list the project authorized?
493
946
  *
494
- * It fails closed. An empty list, a malformed list entry, an empty plan, and
495
- * an unlisted hash are all refusals.
947
+ * It fails closed. An empty list, a malformed list entry, an empty plan, plan
948
+ * output the plan adapter cannot read, and an unlisted hash are all refusals.
949
+ *
950
+ * What the hash binds is the plan, not the run that printed it: the summary
951
+ * line and every row's tag and action, with the target and the reviewed
952
+ * source. Alchemy's renderer logs each line through Effect's pretty console
953
+ * logger and prints its own elapsed duration, so the verbatim output carries
954
+ * a timestamp per line, a fiber id, `Plan ready (2.9s)`, and colour when the
955
+ * child inherits `FORCE_COLOR` — all facts about one process, none about the
956
+ * plan. Hashing them meant a reviewed plan could never be redeployed at its
957
+ * reviewed hash (#1232), so `canonicalPlan` is hashed instead.
496
958
  */
497
959
  const sha256 = (value) => createHash("sha256").update(value).digest("hex");
960
+ /**
961
+ * Which plan representation the payload hashes. It is part of the payload so
962
+ * a hash can never be read as one of a different shape.
963
+ */
964
+ const PLAN_HASH_FORMAT = "canonical-plan-v1";
498
965
  const HASH_PATTERN = /^[0-9a-f]{64}$/u;
499
966
  const SOURCE_SHA_PATTERN = /^[0-9a-f]{40}$/u;
500
967
  /**
501
- * The hash a first deploy is authorized by: the plan text, the target, and
502
- * the reviewed source, in one payload. The plan text is hashed before it
503
- * enters the payload so the value that is compared never embeds plan output.
968
+ * The hash a first deploy is authorized by: the plan, the target, and the
969
+ * reviewed source, in one payload.
970
+ *
971
+ * The plan enters the payload as the sha256 of `canonicalPlan`, so the value
972
+ * that is compared never embeds plan output, and two runs of one plan agree.
973
+ * Bound: the summary line, every row's tag and action (resource, binding and
974
+ * task rows alike, `noop` included), which of those rows are resources and
975
+ * which are bindings, the stack, the stage, and the source SHA. Not bound:
976
+ * logger timestamps and fiber ids, the run's elapsed durations, ANSI colour,
977
+ * progress lines, and the order the rows were printed in.
978
+ *
979
+ * Row order is not bound but the reading of the rows is, because they are
980
+ * not the same fact. Order alone used to carry which rows are a resource's
981
+ * bindings and which are resources inside its namespace; the canonical form
982
+ * states that outright, so two plans creating different resources can never
983
+ * agree merely by printing one multiset of rows two ways.
984
+ *
985
+ * `planFormat` names that shape. A hash issued by an earlier version of this
986
+ * function, which hashed the verbatim output, can never equal one issued
987
+ * now — those hashes were not reproducible in the first place, so they
988
+ * authorized nothing that could be deployed.
989
+ *
990
+ * Throws a `PlanEffectsError` when the plan adapter cannot account for the
991
+ * output: an unreadable plan is refused, never hashed.
504
992
  */
505
993
  const firstDeployPlanHash = ({ planText, sourceSha, stack, stage }) => {
506
994
  if (!(stack.trim() && stage.trim())) throw new Error("First-deploy authorization requires a stack and a stage.");
@@ -510,7 +998,8 @@ const firstDeployPlanHash = ({ planText, sourceSha, stack, stage }) => {
510
998
  if (!planText.isWellFormed()) throw new Error("First-deploy authorization requires well-formed plan output.");
511
999
  return sha256(JSON.stringify({
512
1000
  operation: "deploy",
513
- planTextSha256: sha256(planText),
1001
+ planFormat: PLAN_HASH_FORMAT,
1002
+ planSha256: sha256(canonicalPlan(planText)),
514
1003
  sourceSha,
515
1004
  stack,
516
1005
  stage
@@ -775,121 +1264,108 @@ var AlchemyLifecycleError = class extends Error {
775
1264
  };
776
1265
  const isAlchemyLifecycleError = (error) => error instanceof AlchemyLifecycleError;
777
1266
  //#endregion
778
- //#region src/alchemy/plan-effects.ts
1267
+ //#region src/alchemy/state-tree.ts
779
1268
  /**
780
- * The plan-output adapter (#1077): the one reader of what the installed
781
- * Alchemy CLI prints for a plan.
1269
+ * One read and delete surface over a state layer.
782
1270
  *
783
- * Alchemy has no structured plan output at the fleet baseline, so the plan
784
- * is read from the lines `formatPlanLines` in `alchemy/src/Cli/LoggingCli.ts`
785
- * prints: one `Plan:` summary and one `[id] action` line per resource.
786
- * Paitronage and HQ each wrote this reader; this is the one copy, and it is
787
- * bound to the Alchemy version whose renderer it reads. A consumer running a
788
- * different Alchemy is refused by `runAlchemyLifecycle` before any child
789
- * runs, so a renderer change cannot be read as a plan with different effects.
1271
+ * Alchemy's `State/Tree` module answers "which `(stack, stage)` pairs does
1272
+ * this store hold" and "remove one stage" against the `State` service
1273
+ * interface, so it works over any state layer a consumer configured,
1274
+ * including `@patronage/alchemy-d1-state`. This module is the typed entry to
1275
+ * those two operations plus the record read, and it launches no child
1276
+ * process: every answer comes from the state layer the caller passes in.
790
1277
  *
791
- * The adapter fails on anything it does not recognise: no summary, two
792
- * summaries, an action outside the known set, a summary it cannot parse, or
793
- * a summary whose counts do not equal the lines under it. An effect it could
794
- * not account for is never reported as zero.
1278
+ * The caller owns the state layer and the policy. This module owns the
1279
+ * address: a `(stack, stage)` pair is one stack and one stage, and nothing
1280
+ * else. `Tree` addresses a stage by the path `stack/stage`, so an empty or
1281
+ * padded stage collapses that path to the stack and a delete meant for one
1282
+ * stage removes every stage of the stack. `assertStageAddress` refuses those
1283
+ * inputs before any path is built.
1284
+ *
1285
+ * Which stages a consumer may delete stays with the consumer: this module
1286
+ * deletes the stage it is given.
795
1287
  */
796
- /** The Alchemy version whose plan renderer this adapter reads. */
797
- const PLAN_OUTPUT_ALCHEMY_VERSION = ALCHEMY_BASELINE.alchemy;
798
- const PLAN_ACTIONS = [
799
- "create",
800
- "update",
801
- "replace",
802
- "delete",
803
- "noop"
804
- ];
805
- /** The adapter could not read the output. `reason` is the whole detail. */
806
- var PlanEffectsError = class extends Error {
807
- reason;
808
- constructor(reason) {
809
- super(`Alchemy plan output was not readable: ${reason}.`);
810
- this.name = "PlanEffectsError";
811
- this.reason = reason;
812
- }
813
- };
814
- const ACTION_SET = new Set(PLAN_ACTIONS);
815
- const ansiPattern = new RegExp(`${String.fromCodePoint(27)}\\[[0-?]*[ -/]*[@-~]`, "gu");
816
- const ROW_PATTERN = /^\[(?<tag>[^\]]+)\]\s+(?<action>\S+)/u;
817
- const SUMMARY_PREFIX = "Plan:";
818
- const NO_CHANGES = "Plan: no changes";
819
- const SUMMARY_ITEM = /^(?<count>\d+) to (?<action>create|update|replace|delete|noop)$/u;
820
- const zeroCounts = () => ({
821
- create: 0,
822
- delete: 0,
823
- noop: 0,
824
- replace: 0,
825
- update: 0
826
- });
827
- const isPlanAction = (action) => ACTION_SET.has(action);
828
- /** The rows under the summary, up to the first line that is not a row. */
829
- const rowsUnder = (lines, summaryIndex) => {
830
- const rows = [];
831
- for (const line of lines.slice(summaryIndex + 1)) {
832
- const match = ROW_PATTERN.exec(line);
833
- if (match?.groups === void 0) break;
834
- const { action, tag } = match.groups;
835
- if (action === void 0 || tag === void 0 || !isPlanAction(action)) throw new PlanEffectsError("unknown-action");
836
- if (!tag.includes("/")) rows.push({
837
- action,
838
- id: tag
839
- });
840
- }
841
- return rows;
1288
+ const isRecord$1 = (value) => typeof value === "object" && value !== null;
1289
+ /**
1290
+ * Refuse a stack or stage name that does not address exactly one scope.
1291
+ *
1292
+ * `Tree` splits a path on `/` and drops empty and `.` segments, so `""`,
1293
+ * `"."`, a padded name and a name holding a separator all address something
1294
+ * other than the one stage the caller named. A stage read from an
1295
+ * environment variable carries a trailing newline often enough to matter.
1296
+ */
1297
+ const assertStageAddress = (field, value) => {
1298
+ if (value !== value.trim() || value === "") throw new Error(`A state ${field} must be a non-empty name with no surrounding whitespace.`);
1299
+ if (value.includes("/") || value === "." || value === "..") throw new Error(`A state ${field} must name one scope: '/', '.' and '..' are not allowed.`);
842
1300
  };
1301
+ /** Run one `Tree` effect over the caller's state layer. */
1302
+ const overState = (state, effect) => Effect.runPromise(Effect.provideService(effect, State, Effect.succeed(state)));
843
1303
  /**
844
- * The counts the summary states, in the order Alchemy prints them. The
845
- * summary is rebuilt from what was read and must equal the line, so a
846
- * summary with an extra clause, a repeated action, or a different order is
847
- * refused instead of partially read.
1304
+ * Every `(stack, stage)` pair the state layer holds, or the subset a filter
1305
+ * pins. Ordered by stack then stage.
1306
+ *
1307
+ * The pairs come from the store's own `listStacks` and `listStages`, so an
1308
+ * empty result is the store reporting nothing, never a read this module
1309
+ * could not make: a state layer that cannot be read fails instead.
848
1310
  */
849
- const countsStated = (summary) => {
850
- const counts = zeroCounts();
851
- const stated = [];
852
- for (const item of summary.slice(5).trim().split(", ")) {
853
- const match = SUMMARY_ITEM.exec(item);
854
- const action = match?.groups?.action;
855
- const count = Number(match?.groups?.count);
856
- if (action === void 0 || !isPlanAction(action) || !Number.isSafeInteger(count) || stated.includes(action)) throw new PlanEffectsError("summary-unrecognized");
857
- counts[action] = count;
858
- stated.push(action);
859
- }
860
- const canonical = PLAN_ACTIONS.filter((action) => counts[action] > 0).map((action) => `${counts[action]} to ${action}`).join(", ");
861
- if (stated.length === 0 || `${SUMMARY_PREFIX} ${canonical}` !== summary) throw new PlanEffectsError("summary-unrecognized");
862
- return counts;
1311
+ const inventoryStages = async (state, filter = {}) => {
1312
+ if (filter.stack !== void 0) assertStageAddress("stack", filter.stack);
1313
+ if (filter.stage !== void 0) assertStageAddress("stage", filter.stage);
1314
+ return (await overState(state, allStages(filter))).map(({ stack, stage }) => ({
1315
+ stack,
1316
+ stage
1317
+ }));
863
1318
  };
864
1319
  /**
865
- * Read one plan from Alchemy CLI output.
1320
+ * The persisted-state document for one stage, as `parseStateSnapshot` reads
1321
+ * it: `{ resources: [{ stack, stage, fqn, state }] }`, ordered by fqn.
866
1322
  *
867
- * Throws `PlanEffectsError` on output the adapter cannot account for. The
868
- * output itself never appears in the error.
1323
+ * The read is the one `runAlchemyLifecycle` already makes — the atomic stage
1324
+ * view, then each listed resource record — so a caller holding the document
1325
+ * and a caller holding the lifecycle see the same records. A listed resource
1326
+ * with no readable record fails the read; it is never left out, because a
1327
+ * short document reads as a stage with less in it than it has.
1328
+ *
1329
+ * A stage the store does not hold reads as a document with no resource in
1330
+ * it, the same reading the lifecycle takes for a first deploy. Only a stage
1331
+ * the store cannot read fails. `deleteStageRows` answers an absent stage the
1332
+ * other way, because it addresses a path rather than a stage view.
869
1333
  */
870
- const parsePlanEffects = (output) => {
871
- const lines = output.replaceAll(ansiPattern, "").split("\n").map((line) => line.trim());
872
- const summaryIndexes = lines.flatMap((line, index) => line.startsWith(SUMMARY_PREFIX) ? [index] : []);
873
- if (summaryIndexes.length === 0) throw new PlanEffectsError("no-summary");
874
- if (summaryIndexes.length > 1) throw new PlanEffectsError("multiple-summaries");
875
- const summaryIndex = summaryIndexes[0] ?? 0;
876
- const summary = lines[summaryIndex] ?? "";
877
- const resources = rowsUnder(lines, summaryIndex);
878
- if (summary === NO_CHANGES) {
879
- if (resources.length > 0) throw new PlanEffectsError("effects-under-no-changes");
880
- return {
881
- counts: zeroCounts(),
882
- resources
883
- };
884
- }
885
- const counts = countsStated(summary);
886
- const seen = zeroCounts();
887
- for (const { action } of resources) seen[action] += 1;
888
- if (PLAN_ACTIONS.some((action) => seen[action] !== counts[action])) throw new PlanEffectsError("count-mismatch");
889
- return {
890
- counts,
891
- resources
1334
+ const readStageSnapshot = async (state, target) => {
1335
+ assertStageAddress("stack", target.stack);
1336
+ assertStageAddress("stage", target.stage);
1337
+ const scope = {
1338
+ stack: target.stack,
1339
+ stage: target.stage
892
1340
  };
1341
+ const view = await Effect.runPromise(state.snapshotStage(scope));
1342
+ if (!Array.isArray(view.resources) || view.resources.some((fqn) => typeof fqn !== "string" || fqn === "")) throw new Error(`The state layer returned no resource list for ${target.stack} in ${target.stage}.`);
1343
+ const resources = await Promise.all([...view.resources].toSorted().map(async (fqn) => ({
1344
+ ...scope,
1345
+ fqn,
1346
+ state: await Effect.runPromise(state.get({
1347
+ ...scope,
1348
+ fqn
1349
+ }))
1350
+ })));
1351
+ if (resources.some((resource) => !isRecord$1(resource.state))) throw new Error(`The state layer listed a resource it could not read for ${target.stack} in ${target.stage}.`);
1352
+ return JSON.stringify({ resources });
1353
+ };
1354
+ /**
1355
+ * Remove every row the state layer holds for one stage. Other stages of the
1356
+ * same stack keep every row.
1357
+ *
1358
+ * A stage the store does not hold is refused by the state layer rather than
1359
+ * reported as removed, so a caller never reads a mistyped stage as a stage
1360
+ * that was already empty.
1361
+ */
1362
+ const deleteStageRows = async (state, target) => {
1363
+ assertStageAddress("stack", target.stack);
1364
+ assertStageAddress("stage", target.stage);
1365
+ await overState(state, deleteState({
1366
+ path: `${target.stack}/${target.stage}`,
1367
+ recursive: true
1368
+ }));
893
1369
  };
894
1370
  //#endregion
895
1371
  //#region src/alchemy/lifecycle.ts
@@ -962,25 +1438,26 @@ const persistedToSnapshot = (target, persisted) => ({ resources: persisted.resou
962
1438
  */
963
1439
  const IN_FLIGHT_STATUSES = new Set(["creating", "deleting"]);
964
1440
  const statusOf = (record) => isRecord(record.state) ? record.state.status : void 0;
965
- /** The leaf of a persisted FQN: what Alchemy prints as a plan row's tag. */
966
- const leafOf = (fqn) => fqn.slice(fqn.lastIndexOf("/") + 1);
967
1441
  /**
968
- * Resolve plan rows, which carry Alchemy's rendered logical id, onto
969
- * persisted FQNs. A rendered id is not an identity: `old/Worker` and
970
- * `new/Worker` both print as `[Worker]`. A row resolves only when exactly one
971
- * candidate FQN has that leaf; zero or several is `undefined`, and the
972
- * caller refuses rather than guessing.
1442
+ * Resolve plan rows onto persisted FQNs. A row's `id` is the FQN Alchemy
1443
+ * printed in its tag, which is the identity the resource is persisted under
1444
+ * (epic #1165, D1), so a row resolves only to the candidate that is that
1445
+ * FQN exactly. A row with no candidate is `undefined`, and the caller
1446
+ * refuses rather than guessing.
973
1447
  */
974
1448
  const resolveRows = (rows, candidates) => {
975
1449
  const resolved = [];
976
1450
  for (const { id } of rows) {
977
- const matches = candidates.filter((fqn) => leafOf(fqn) === id);
978
- if (matches.length !== 1 || matches[0] === void 0) return;
979
- resolved.push(matches[0]);
1451
+ if (!candidates.includes(id)) return;
1452
+ resolved.push(id);
980
1453
  }
981
1454
  return resolved;
982
1455
  };
983
- /** A plan whose rows print one id twice cannot name its resources. */
1456
+ /**
1457
+ * Two rows with one FQN name one persisted identity twice. Alchemy keys a
1458
+ * plan by FQN, so its renderer cannot print this; output that does is not a
1459
+ * plan this operation can bind to, and is refused rather than read.
1460
+ */
984
1461
  const hasRepeatedIds = (effects) => new Set(effects.resources.map(({ id }) => id)).size !== effects.resources.length;
985
1462
  /** Whether a lease still fences the stage, by the store's own fenced renewal. */
986
1463
  const fenceState = async (lease) => {
@@ -1180,9 +1657,6 @@ var LifecycleRun = class {
1180
1657
  resources: records
1181
1658
  };
1182
1659
  }
1183
- profileArgs() {
1184
- return this.policy.profile === void 0 ? [] : ["--profile", this.policy.profile];
1185
- }
1186
1660
  stageArgs() {
1187
1661
  return ["--stage", this.request.stage];
1188
1662
  }
@@ -1232,7 +1706,6 @@ var LifecycleRun = class {
1232
1706
  "deploy",
1233
1707
  "--dry-run",
1234
1708
  ...adopt ? ["--adopt"] : [],
1235
- ...this.profileArgs(),
1236
1709
  ...this.stageArgs()
1237
1710
  ];
1238
1711
  }
@@ -1296,7 +1769,7 @@ var LifecycleRun = class {
1296
1769
  };
1297
1770
  }
1298
1771
  admitFirstDeploy(effects, text) {
1299
- if (effects.counts.update + effects.counts.replace + effects.counts.delete > 0) throw this.fail("EFFECT_NOT_PERMITTED", "admission");
1772
+ if (effects.counts.update + effects.counts.replace + effects.counts.delete + effects.counts.orphaned > 0) throw this.fail("EFFECT_NOT_PERMITTED", "admission");
1300
1773
  const planHash = this.planHashOf(text);
1301
1774
  const { firstDeploy } = this.policy;
1302
1775
  if (firstDeploy === void 0 || planHash === void 0) throw this.fail("FIRST_DEPLOY_UNAUTHORIZED", "admission");
@@ -1336,7 +1809,7 @@ var LifecycleRun = class {
1336
1809
  return;
1337
1810
  }
1338
1811
  if (!isProtected) return;
1339
- if (effects.counts.replace + effects.counts.delete > 0) throw this.fail("EFFECT_NOT_PERMITTED", "admission");
1812
+ if (effects.counts.replace + effects.counts.delete + effects.counts.orphaned > 0) throw this.fail("EFFECT_NOT_PERMITTED", "admission");
1340
1813
  this.assertRetained(persistedToSnapshot(this.target, before), void 0, "admission");
1341
1814
  }
1342
1815
  /**
@@ -1348,14 +1821,11 @@ var LifecycleRun = class {
1348
1821
  * FQN, and that recovery is the ordinary accident this operation exists
1349
1822
  * for.
1350
1823
  *
1351
- * A plan row carries Alchemy's rendered logical id and no namespace, so
1352
- * the only FQN a row names exactly is a top-level one, where
1353
- * `toFqn(undefined, id) === id`. That equality cannot collide. An
1354
- * in-flight record under a namespace (`other/Worker`) is not the FQN a
1355
- * `[Worker]` row names, so it never qualifies as a recovery, even when its
1356
- * leaf matches; a nested recovery is therefore refused rather than
1357
- * guessed. Zero candidates is a create that did not happen; several is an
1358
- * identity the plan cannot name.
1824
+ * A plan row carries the resource's FQN, so a row names one persisted
1825
+ * identity exactly, under any namespace. An in-flight record at another
1826
+ * FQN (`other/Worker` for a `[Worker]` row) is not the one the row names,
1827
+ * so it never qualifies as a recovery. No candidate is a create that did
1828
+ * not happen.
1359
1829
  */
1360
1830
  createdFqns(before, after, effects) {
1361
1831
  const planned = effects.resources.filter(({ action }) => action === "create");
@@ -1368,6 +1838,8 @@ var LifecycleRun = class {
1368
1838
  }
1369
1839
  assertConverged(effects, after) {
1370
1840
  const tolerated = new Set(this.policy.nonConvergentResources);
1841
+ if (effects.bindingChanges > 0) throw this.fail("NOT_CONVERGED", "postcondition");
1842
+ if (effects.tasks.length > 0) throw this.fail("NOT_CONVERGED", "postcondition");
1371
1843
  const pending = effects.resources.filter(({ action }) => action !== "noop");
1372
1844
  const fqns = resolveRows(pending, after.resources.map(({ fqn }) => fqn));
1373
1845
  if (fqns === void 0) throw this.fail("NOT_CONVERGED", "postcondition");
@@ -1382,7 +1854,6 @@ var LifecycleRun = class {
1382
1854
  await this.runChild("deploy", [
1383
1855
  "deploy",
1384
1856
  ...adopt ? ["--adopt"] : [],
1385
- ...this.profileArgs(),
1386
1857
  ...this.stageArgs(),
1387
1858
  "--yes"
1388
1859
  ], lease);
@@ -1415,7 +1886,6 @@ var LifecycleRun = class {
1415
1886
  const { cleanup, effects } = await this.withLease("state", "postcondition", async (lease, accept) => {
1416
1887
  const text = await this.runChild("destroy", [
1417
1888
  "destroy",
1418
- ...this.profileArgs(),
1419
1889
  ...this.stageArgs(),
1420
1890
  "--yes"
1421
1891
  ], lease);
@@ -1457,7 +1927,8 @@ var LifecycleRun = class {
1457
1927
  * apply child, then the persisted state again: it must record what the plan
1458
1928
  * created and, on a protected stage, the same identities as before. Then a
1459
1929
  * second dry-run plan, which must be a no-op except an `update` on a resource
1460
- * the policy names non-convergent.
1930
+ * the policy names non-convergent. A binding change or a task row in that
1931
+ * plan is work left behind, and no policy tolerates it.
1461
1932
  *
1462
1933
  * `destroy`: refused on a protected stage. Then, under the stage lease, the
1463
1934
  * destroy child, then the atomic stage view must be empty and the empty
@@ -1505,4 +1976,4 @@ const runAlchemyLifecycle = async (options) => {
1505
1976
  /** The specifier a consumer imports this surface by. */
1506
1977
  const ALCHEMY_SUBPATH = "@patronage/factory-ci/alchemy";
1507
1978
  //#endregion
1508
- export { ALCHEMY_SUBPATH, AlchemyLifecycleError, CloudflareCredentialUnavailableError, HOSTED_CLOUDFLARE_CREDENTIAL_NAMES, LIFECYCLE_FAILURE_MESSAGES, LOCAL_PREVIEW_STAGES, MINIMUM_REDACTABLE_SECRET_LENGTH, PLAN_ACTIONS, PLAN_OUTPUT_ALCHEMY_VERSION, PlanEffectsError, RetainedIdentityError, SECRET_REDACTION_PLACEHOLDER, StagePolicyError, UrlImpliesAuthError, anyStage, assertDestroyLeftNothing, assertRetainedIdentities, assertUrlImpliesAuth, credentialPreflight, evaluateStack, firstDeployAuthorization, firstDeployPlanHash, isAlchemyLifecycleError, parsePlanEffects, parseStateSnapshot, runAlchemyLifecycle, secretNamePolicy, stagePolicy };
1979
+ export { ALCHEMY_SUBPATH, AlchemyLifecycleError, CloudflareCredentialUnavailableError, HOSTED_CLOUDFLARE_CREDENTIAL_NAMES, LIFECYCLE_FAILURE_MESSAGES, LOCAL_PREVIEW_STAGES, MINIMUM_REDACTABLE_SECRET_LENGTH, PLAN_ACTIONS, PLAN_OUTPUT_ALCHEMY_VERSION, PlanEffectsError, RetainedIdentityError, SECRET_REDACTION_PLACEHOLDER, StagePolicyError, UrlImpliesAuthError, anyStage, assertDestroyLeftNothing, assertRetainedIdentities, assertUrlImpliesAuth, credentialPreflight, deleteStageRows, evaluateStack, firstDeployAuthorization, firstDeployPlanHash, inventoryStages, isAlchemyLifecycleError, isLocalEmulationStage, localEmulationEnvironment, parsePlanEffects, parseStateSnapshot, readStageSnapshot, runAlchemyLifecycle, secretNamePolicy, stagePolicy };