@homeflare/alchemy 0.10.0 → 0.10.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.
@@ -33,6 +33,14 @@ export const Thing = Alchemy.Resource<Thing>('Test.Thing');
33
33
  export interface Blind extends Alchemy.Resource<'Test.Blind', ThingProps, ThingAttributes> {}
34
34
  export const Blind = Alchemy.Resource<Blind>('Test.Blind');
35
35
 
36
+ /**
37
+ * The same object with a `diff` that compares the RECORDED props with the declaration and never
38
+ * the live object — the shape of Alchemy's own `Cloudflare.R2Bucket` diff (beta.79
39
+ * Cloudflare/R2/Bucket.ts: `olds.domains` against `news.domains`). recheck.ts exists for it.
40
+ */
41
+ export interface Recorded extends Alchemy.Resource<'Test.Recorded', ThingProps, ThingAttributes> {}
42
+ export const Recorded = Alchemy.Resource<Recorded>('Test.Recorded');
43
+
36
44
  /** The fake cloud: what exists, what is someone else's, and every call made to it, in order. */
37
45
  export interface Cloud {
38
46
  readonly live: Map<string, string>;
@@ -93,6 +101,22 @@ export const thingProviders = (cloud: Cloud) =>
93
101
  ),
94
102
  ),
95
103
  Provider.effect(Blind, Effect.succeed(Blind.Provider.of(lifecycle(cloud)))),
104
+ Provider.effect(
105
+ Recorded,
106
+ Effect.succeed(
107
+ Recorded.Provider.of({
108
+ ...lifecycle(cloud),
109
+ diff: ({ news, olds }) =>
110
+ Effect.sync(() => {
111
+ if (!isResolved(news)) return undefined;
112
+ cloud.calls.push(`diff ${news.name}`);
113
+ return (olds.comment ?? '') === (news.comment ?? '')
114
+ ? ({ action: 'noop' } as const)
115
+ : ({ action: 'update' } as const);
116
+ }),
117
+ }),
118
+ ),
119
+ ),
96
120
  );
97
121
 
98
122
  /** A stack body: resource declarations, as an `alchemy.run.ts` would write them. */
@@ -22,6 +22,7 @@ export {
22
22
  type DiffAnswer,
23
23
  type Observations,
24
24
  type ReadAnswer,
25
+ type RecheckAnswer,
25
26
  type Seen,
26
27
  WRITE_REFUSED,
27
28
  spyContext,
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The scope Plan.ts gives every provider call it makes, for the calls the verifier makes itself
3
+ * after the plan: `--all`'s reads (verify.ts) and the recheck's diffs (recheck.ts).
4
+ *
5
+ * ⚠️ RE-CREATED, BECAUSE Plan.ts DOES NOT EXPORT IT (`providePlanScope`, alchemy beta.79): a scoped
6
+ * `Artifacts` bag over the run's `ArtifactStore` (a fresh one when none is provided) and the row's
7
+ * `InstanceId`. A kit family that consults either — the ownership helpers do — sees what it
8
+ * would see under `alchemy plan`. Moved here from verify.ts once a second caller needed it.
9
+ */
10
+ import {
11
+ ArtifactStore,
12
+ Artifacts,
13
+ createArtifactStore,
14
+ makeScopedArtifacts,
15
+ } from 'alchemy/Artifacts';
16
+ import { InstanceId } from 'alchemy/InstanceId';
17
+ import * as Effect from 'effect/Effect';
18
+ import * as Option from 'effect/Option';
19
+
20
+ export const inPlanScope =
21
+ (fqn: string, instanceId: string) =>
22
+ <A, E, R>(
23
+ effect: Effect.Effect<A, E, R>,
24
+ ): Effect.Effect<A, E, Exclude<R, Artifacts | InstanceId>> =>
25
+ Effect.gen(function* () {
26
+ const store = Option.getOrElse(
27
+ yield* Effect.serviceOption(ArtifactStore),
28
+ createArtifactStore,
29
+ );
30
+ return yield* effect.pipe(
31
+ Effect.provideService(Artifacts, makeScopedArtifacts(store, fqn)),
32
+ Effect.provideService(InstanceId, instanceId),
33
+ );
34
+ }) as Effect.Effect<A, E, Exclude<R, Artifacts | InstanceId>>;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * An adopted row whose provider's `diff` never looks at the live object (recheck.ts).
3
+ *
4
+ * 🔴 THE FALSE PASS THIS PINS (red-teamed 2026-09-21). Plan.ts hands an adopted row's diff
5
+ * `olds: news`, so a diff that compares recorded props with the declaration answers `noop`
6
+ * whatever the cloud holds. The verifier printed `ok` and exited 0 over a drift it had itself
7
+ * listed under `changed`, and the deploy then wrote it. The first test fails that way on the
8
+ * verifier without recheck.ts.
9
+ */
10
+ import { describe, expect, test } from 'bun:test';
11
+ import * as Effect from 'effect/Effect';
12
+ import { Recorded, Thing, cloudOf, fakeEngine, writesOf } from './fake-engine.ts';
13
+ import { exitCodeOf } from './report.ts';
14
+
15
+ const recorded = (name: string, comment: string) => Recorded(name, { comment, name });
16
+
17
+ describe('an adopted row whose diff compares recorded props', () => {
18
+ test('that drifted: asked again with the live value, it says update, and the row fails', async () => {
19
+ const cloud = cloudOf({ drifted: 'old' });
20
+ const engine = fakeEngine(cloud);
21
+ const report = await engine.verify(recorded('drifted', 'new'));
22
+ expect(report.rows).toEqual([
23
+ expect.objectContaining({
24
+ changed: ['comment'],
25
+ diff: 'noop',
26
+ ok: false,
27
+ planned: 'adopted',
28
+ recheck: 'update',
29
+ }),
30
+ ]);
31
+ expect(report.rows[0]?.why).toContain('recorded props');
32
+ expect(exitCodeOf(report)).toBe(1);
33
+ expect(writesOf(cloud)).toEqual([]);
34
+
35
+ // ★ And the verdict is the deploy's: the forced reconcile writes the drift the diff hid.
36
+ expect(await engine.deploy(recorded('drifted', 'new'))).toEqual({ drifted: 'adopted' });
37
+ expect(cloud.live.get('drifted')).toBe('new');
38
+ });
39
+
40
+ test('that matches: nothing differs, so it is not asked again and passes', async () => {
41
+ const cloud = cloudOf({ same: 'x' });
42
+ const report = await fakeEngine(cloud).verify(recorded('same', 'x'));
43
+ expect(report.rows[0]).toMatchObject({ changed: [], diff: 'noop', ok: true });
44
+ expect(report.rows[0]?.recheck).toBeUndefined();
45
+ expect(cloud.calls.filter((call) => call.startsWith('diff'))).toEqual(['diff same']);
46
+ });
47
+
48
+ test('a diff that reads the live object is not asked again when it already said update', async () => {
49
+ const cloud = cloudOf({ drifted: 'old' });
50
+ const report = await fakeEngine(cloud).verify(
51
+ Effect.all([Thing('drifted', { comment: 'new', name: 'drifted' })]),
52
+ );
53
+ expect(report.rows[0]).toMatchObject({ diff: 'update', ok: false });
54
+ expect(report.rows[0]?.recheck).toBeUndefined();
55
+ });
56
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Ask an adopted row's `diff` a second time, with the live values as its recorded props, when its
3
+ * first `noop` sits beside a read that differs from the declaration.
4
+ *
5
+ * 🔴 WHY (red-teamed 2026-09-21). Plan.ts hands an adopted row's diff `olds: news`, because the
6
+ * adopted state carries `props: news`. A diff that compares the RECORDED props with the
7
+ * declaration therefore answers `noop` whatever the cloud holds, and the forced reconcile then
8
+ * writes. Alchemy's own `Cloudflare.R2Bucket` diff is that shape (beta.79
9
+ * Cloudflare/R2/Bucket.ts, `olds.domains` against `news.domains`). The verifier said `ok`,
10
+ * exited 0, and listed the drift under `changed` on the same line. rows.ts already refused to
11
+ * trust a MISSING diff for this reason; a diff that never looks at the live object is the same
12
+ * blind spot one level down.
13
+ *
14
+ * ★ THE QUESTION IS "WOULD YOU STILL SAY noop IF STATE HAD RECORDED WHAT THE READ FOUND?" It is the
15
+ * planner's own input, replayed with one change: `olds` carries the read's value for each field
16
+ * in `changed`. A diff that reads the live object ignores `olds` and answers as before. Every kit
17
+ * PVE/PBS family re-reads (`pveOperations.diff`), so a field its `matches` leaves out, such as
18
+ * CephPool's autoscaled `pg_num`, still passes. A diff that compares `olds` now sees the drift.
19
+ *
20
+ * ⛔ ONLY `diff`, a read by Alchemy's contract, and through the WATCHED provider, so every write
21
+ * path still answers with spy.ts's refusal. The first answer is put back afterwards: the report's
22
+ * `diff` column stays what the planner heard.
23
+ * ⚠️ IT CAN ONLY TURN A PASS INTO A FAIL. It runs on a row that would otherwise pass (adopted,
24
+ * `noop`, something in `changed`), and anything but a second `noop`, a failure included, fails it.
25
+ * ⚠️ SAME-NAMED FIELDS ONLY, like `changed`: a drift in a field whose attribute has another name is
26
+ * invisible to both. Whether a `reconcile` PUTs identical values anyway is still up to the
27
+ * provider (docs/adopt-verify.md, Limits).
28
+ */
29
+ import type { ProviderService } from 'alchemy/Provider';
30
+ import * as Effect from 'effect/Effect';
31
+ import { inPlanScope } from './plan-scope.ts';
32
+ import { changedFields } from './rows.ts';
33
+ import { type Observations, type RecheckAnswer, diffAnswer } from './spy.ts';
34
+
35
+ /** The plan-node fields a recheck needs. */
36
+ export interface RecheckNode {
37
+ readonly action: string;
38
+ readonly provider?: ProviderService;
39
+ readonly state?: { readonly instanceId: string };
40
+ }
41
+
42
+ /** Recheck one planned row if it needs it; the answer lands in `seen` as `recheck`. */
43
+ export const recheck = (fqn: string, node: RecheckNode, seen: Observations): Effect.Effect<void> =>
44
+ Effect.gen(function* () {
45
+ const observed = seen.get(fqn);
46
+ const first = observed?.diff;
47
+ const input = first?.input;
48
+ const diff = node.provider?.diff;
49
+ if (node.action !== 'adopted' || first?.answer !== 'noop' || input === undefined) return;
50
+ const live = observed?.read?.attributes as Record<string, unknown> | undefined;
51
+ const changed = changedFields(input.news, live);
52
+ if (live === undefined || changed.length === 0) return;
53
+ if (diff === undefined || node.state === undefined) return;
54
+ const olds = {
55
+ ...(input.olds as object),
56
+ ...Object.fromEntries(changed.map((key) => [key, live[key]])),
57
+ };
58
+ // ⚠️ THE CAST IS THE ENGINE'S BOUNDARY, as in verify.ts: the diff's requirements are the
59
+ // session's, which verifySession provides around this call.
60
+ const asked = diff({ ...input, olds } as typeof input) as Effect.Effect<unknown>;
61
+ const answer: RecheckAnswer = yield* asked.pipe(
62
+ inPlanScope(fqn, node.state.instanceId),
63
+ Effect.map(diffAnswer),
64
+ Effect.catchCause(() => Effect.succeed('failed' as const)),
65
+ );
66
+ // The watched diff recorded the replay over the planner's answer; put the planner's back.
67
+ if (observed !== undefined) {
68
+ observed.diff = first;
69
+ observed.recheck = answer;
70
+ }
71
+ });
@@ -7,12 +7,15 @@
7
7
  * this file only decides.
8
8
  */
9
9
  import { deepEqual, isResolved } from 'alchemy/Diff';
10
- import type { DiffAnswer, Observations, ReadAnswer } from './spy.ts';
10
+ import type { DiffAnswer, Observations, ReadAnswer, RecheckAnswer } from './spy.ts';
11
11
 
12
12
  export interface AdoptRow {
13
13
  readonly fqn: string;
14
14
  readonly type: string;
15
- /** Whether the state store holds a row for this FQN. An adoption is a row without one. */
15
+ /**
16
+ * Whether the row plans from a row the state store holds for it (verify.ts `withState`). An
17
+ * adoption is a row without one.
18
+ */
16
19
  readonly stateRow: boolean;
17
20
  /** The engine's action — what `alchemy plan` prints (`adopted`, `create`, `noop`, …). */
18
21
  readonly planned: string;
@@ -23,9 +26,16 @@ export interface AdoptRow {
23
26
  /**
24
27
  * Declared fields whose live value differs, by NAME only — values can be secrets. Compared on
25
28
  * same-named keys of the declaration and the read's attributes, so a family whose attributes
26
- * rename a field cannot show it here; the diff answer, not this list, is the verdict.
29
+ * rename a field cannot show it here. The diff answer is the verdict; a name here beside an
30
+ * adopted `noop` only makes recheck.ts ask that diff again.
27
31
  */
28
32
  readonly changed: readonly string[];
33
+ /**
34
+ * What the provider's `diff` answered when asked again with the live values of `changed` as its
35
+ * recorded props (recheck.ts). Absent when it was not asked: only an adopted `noop` with
36
+ * something in `changed` is.
37
+ */
38
+ readonly recheck?: RecheckAnswer;
29
39
  /** Bindings the engine will create, update or delete — reconcile applies them even on `noop`. */
30
40
  readonly bindings: number;
31
41
  readonly ok: boolean;
@@ -77,9 +87,38 @@ const isNoop = (planned: string, diff: DiffAnswer, stateRow: boolean) =>
77
87
  STILL_LIVE.has(planned) &&
78
88
  (diff === 'noop' || (diff === 'none' && stateRow && planned === 'noop'));
79
89
 
90
+ /**
91
+ * ⛔ AND A `noop` MUST SURVIVE BEING ASKED WITH THE LIVE VALUES. A diff that compares recorded props
92
+ * answers an adopted row `noop` whatever the cloud holds; recheck.ts asks it again, and only a
93
+ * second `noop` (or no need to ask) proves anything.
94
+ */
95
+ const proven = (recheck: RecheckAnswer | undefined) => recheck === undefined || recheck === 'noop';
96
+
97
+ /** Notes on a row that passes: nothing to say is the common case. */
98
+ const notes = (row: Omit<AdoptRow, 'ok' | 'why'>): string =>
99
+ [
100
+ row.read === 'unowned' ? 'unowned: the deploy needs --adopt or adopt(true)' : '',
101
+ row.recheck === 'noop'
102
+ ? `${row.changed.join(', ')} differ, but the diff says noop even given the live values: ` +
103
+ 'the family does not manage them, and the deploy leaves them alone'
104
+ : '',
105
+ ]
106
+ .filter((note) => note !== '')
107
+ .join('; ');
108
+
80
109
  const reason = (row: Omit<AdoptRow, 'ok' | 'why'>, ok: boolean): string => {
81
- if (ok) return row.read === 'unowned' ? 'unowned: the deploy needs --adopt or adopt(true)' : '';
110
+ if (ok) return notes(row);
82
111
  if (isNoop(row.planned, row.diff, row.stateRow)) {
112
+ const live = `the live ${row.changed.join(', ')}`;
113
+ if (row.recheck === 'failed') {
114
+ return `asked again with ${live} as recorded props, the provider's diff failed: not proven`;
115
+ }
116
+ if (!proven(row.recheck)) {
117
+ return (
118
+ `the provider's diff compares recorded props, not the live object: given ${live} it ` +
119
+ `says ${String(row.recheck)}, and the deploy's forced reconcile writes`
120
+ );
121
+ }
83
122
  return `${String(row.bindings)} binding(s) change: the deploy reconciles them though the diff is noop`;
84
123
  }
85
124
  if (row.planned === 'delete') return 'no longer declared: the deploy deletes it';
@@ -107,6 +146,7 @@ export const rowFor = (
107
146
  seen: Observations,
108
147
  ): AdoptRow => {
109
148
  const observed = seen.get(fqn);
149
+ const recheck = observed?.recheck;
110
150
  const base = {
111
151
  bindings: (node.bindings ?? []).filter((binding) => binding.action !== 'noop').length,
112
152
  changed: changedFields(observed?.diff?.news, observed?.read?.attributes),
@@ -114,6 +154,7 @@ export const rowFor = (
114
154
  fqn,
115
155
  planned: node.action,
116
156
  read: observed?.read?.answer ?? 'not-read',
157
+ ...(recheck === undefined ? {} : { recheck }),
117
158
  stateRow,
118
159
  type: node.resource.Type,
119
160
  } satisfies Omit<AdoptRow, 'ok' | 'why'>;
@@ -122,7 +163,7 @@ export const rowFor = (
122
163
  * non-noop binding to `reconcile`, which an adopted row reaches anyway — a Worker adopted with
123
164
  * a new binding writes although its own diff said nothing changed.
124
165
  */
125
- const ok = isNoop(base.planned, base.diff, stateRow) && base.bindings === 0;
166
+ const ok = isNoop(base.planned, base.diff, stateRow) && proven(recheck) && base.bindings === 0;
126
167
  return { ...base, ok, why: reason(base, ok) };
127
168
  };
128
169
 
package/src/verify/spy.ts CHANGED
@@ -31,11 +31,22 @@ import * as Effect from 'effect/Effect';
31
31
 
32
32
  export type ReadAnswer = 'found' | 'unowned' | 'absent' | 'failed' | 'not-read';
33
33
  export type DiffAnswer = 'noop' | 'update' | 'replace' | 'none' | 'not-run';
34
+ /** What the diff answered when asked again with the live values as its recorded props (recheck.ts). */
35
+ export type RecheckAnswer = DiffAnswer | 'failed';
36
+
37
+ /** Exactly what the planner handed a provider's `diff`. */
38
+ export type DiffInput = Parameters<NonNullable<ProviderService['diff']>>[0];
34
39
 
35
40
  /** What one FQN's provider answered during the plan — the LAST answer of each kind. */
36
41
  export interface Seen {
37
42
  read?: { readonly answer: ReadAnswer; readonly attributes: unknown };
38
- diff?: { readonly answer: DiffAnswer; readonly news: unknown };
43
+ /**
44
+ * ★ `input` IS THE WHOLE CALL, NOT ONLY `news`, so recheck.ts can ask the same question again
45
+ * with one field changed. In memory only; the report carries field names, never values. An
46
+ * observation built by hand without it (rows.ts's tests) is simply never rechecked.
47
+ */
48
+ diff?: { readonly answer: DiffAnswer; readonly news: unknown; readonly input?: DiffInput };
49
+ recheck?: RecheckAnswer;
39
50
  }
40
51
 
41
52
  /** Keyed by FQN. Filled while the plan runs; read once it is done. */
@@ -63,7 +74,7 @@ const refuse = (type: string, operation: string) => () =>
63
74
  const readAnswer = (attributes: unknown): ReadAnswer =>
64
75
  attributes === undefined ? 'absent' : Unowned.is(attributes) ? 'unowned' : 'found';
65
76
 
66
- const diffAnswer = (diff: unknown): DiffAnswer => {
77
+ export const diffAnswer = (diff: unknown): DiffAnswer => {
67
78
  const action = (diff as { action?: unknown } | undefined)?.action;
68
79
  return action === 'noop' || action === 'update' || action === 'replace' ? action : 'none';
69
80
  };
@@ -82,7 +93,7 @@ export const spyService = (type: string, service: ProviderService, seen: Observa
82
93
  (diff === undefined ? Effect.succeed(undefined) : diff(input)).pipe(
83
94
  Effect.tap((answer) =>
84
95
  Effect.sync(() => {
85
- seenFor(seen, input.fqn).diff = { answer: diffAnswer(answer), news: input.news };
96
+ seenFor(seen, input.fqn).diff = { answer: diffAnswer(answer), input, news: input.news };
86
97
  }),
87
98
  ),
88
99
  ),
@@ -8,6 +8,7 @@
8
8
  * this tool can be re-thought rather than silently duplicated.
9
9
  */
10
10
  import { describe, expect, test } from 'bun:test';
11
+ import { renamedFrom } from 'alchemy';
11
12
  import * as Effect from 'effect/Effect';
12
13
  import {
13
14
  Blind,
@@ -112,6 +113,27 @@ describe('verifySession', () => {
112
113
  expect(writesOf(cloud)).toEqual([]);
113
114
  });
114
115
 
116
+ /**
117
+ * ⚠️ A RENAME MAY HAND ITS OLD ID TO A NEW RESOURCE IN THE SAME DEPLOY (alchemy Rename.ts). The
118
+ * store still holds the renamer's row at that id, so judging state by FQN alone hid the new
119
+ * resource's `create` from the default report. Both rows are without state of their own.
120
+ */
121
+ test('a rename that hands its old id to a new resource shows both, the create included', async () => {
122
+ const cloud = cloudOf();
123
+ const engine = fakeEngine(cloud);
124
+ await engine.deploy(one('old', 'x'));
125
+ const body = Effect.all([
126
+ Thing('moved', { comment: 'x', name: 'old' }).pipe(renamedFrom('old')),
127
+ Thing('old', { name: 'fresh' }),
128
+ ]);
129
+ const report = await engine.verify(body);
130
+ const byFqn = Object.fromEntries(report.rows.map((row) => [row.fqn, row]));
131
+ expect(Object.keys(byFqn).sort()).toEqual(['moved', 'old']);
132
+ expect(byFqn['old']).toMatchObject({ ok: false, planned: 'create', stateRow: false });
133
+ expect(byFqn['moved']).toMatchObject({ ok: false, stateRow: false });
134
+ expect(writesOf(cloud)).toEqual(['reconcile old']);
135
+ });
136
+
115
137
  test('with all, a row no longer declared is a pending delete', async () => {
116
138
  const cloud = cloudOf();
117
139
  const engine = fakeEngine(cloud);
@@ -11,25 +11,20 @@
11
11
  *
12
12
  * ⛔ READ-ONLY BY CONSTRUCTION. The planner calls `read` and `diff`; spy.ts replaces `reconcile`,
13
13
  * `delete` and `precreate` with refusals; nothing here calls `apply`. `--all` adds one `read` per
14
- * row with state — still a read by Alchemy's provider contract.
14
+ * row with state, and recheck.ts one more `diff` for an adopted `noop` whose read differs from
15
+ * the declaration — both still reads by Alchemy's provider contract.
15
16
  * ⚠️ READS ARE NOT FREE FOR EVERY FAMILY. A kit PVE/PBS read mints a lease through OpenBao, which
16
17
  * creates a short-lived API token on the cluster, exactly as `alchemy plan` does.
17
18
  */
18
19
  import * as Alchemist from 'alchemy/Alchemist';
19
- import {
20
- ArtifactStore,
21
- Artifacts,
22
- createArtifactStore,
23
- makeScopedArtifacts,
24
- } from 'alchemy/Artifacts';
25
- import { InstanceId } from 'alchemy/InstanceId';
26
20
  import * as Plan from 'alchemy/Plan';
27
21
  import type { ProviderService } from 'alchemy/Provider';
28
22
  import type { CompiledStack } from 'alchemy/Stack';
29
23
  import { State, isActionState } from 'alchemy/State';
30
24
  import type * as Context from 'effect/Context';
31
25
  import * as Effect from 'effect/Effect';
32
- import * as Option from 'effect/Option';
26
+ import { inPlanScope } from './plan-scope.ts';
27
+ import { recheck } from './recheck.ts';
33
28
  import { type AdoptRow, type PlanView, rowsOf } from './rows.ts';
34
29
  import { type Observations, spyContext } from './spy.ts';
35
30
 
@@ -52,8 +47,9 @@ export interface VerifySession {
52
47
  readonly context: Context.Context<unknown>;
53
48
  }
54
49
 
55
- /** The plan-node fields `--all` needs to read a row that has state. */
50
+ /** The plan-node fields `--all` needs to read a row that has state, and the recheck to replay a diff. */
56
51
  type StatefulNode = {
52
+ readonly action: string;
57
53
  readonly provider?: ProviderService;
58
54
  readonly resource: { readonly LogicalId: string };
59
55
  readonly state?: {
@@ -63,37 +59,49 @@ type StatefulNode = {
63
59
  };
64
60
  };
65
61
 
66
- /**
67
- * ⚠️ THE SAME SCOPE Plan.ts GIVES ITS OWN READS (`providePlanScope`, not exported): a scoped
68
- * `Artifacts` bag and the row's `InstanceId`. A kit family that consults either — the ownership
69
- * helpers do — sees what it would see under `alchemy plan`.
70
- */
62
+ /** ⚠️ In the scope Plan.ts gives its own reads (plan-scope.ts). */
71
63
  const readWithState = (fqn: string, node: StatefulNode) =>
72
64
  Effect.gen(function* () {
73
65
  const read = node.provider?.read;
74
66
  if (read === undefined || node.state === undefined) return;
75
- const store = Option.getOrElse(yield* Effect.serviceOption(ArtifactStore), createArtifactStore);
76
67
  yield* read({
77
68
  fqn,
78
69
  id: node.resource.LogicalId,
79
70
  instanceId: node.state.instanceId,
80
71
  olds: node.state.props,
81
72
  output: node.state.attr,
82
- }).pipe(
83
- Effect.provideService(Artifacts, makeScopedArtifacts(store, fqn)),
84
- Effect.provideService(InstanceId, node.state.instanceId),
85
- );
73
+ }).pipe(inPlanScope(fqn, node.state.instanceId));
86
74
  });
87
75
 
88
- /** Which of `fqns` the stage's state store holds a resource row for. */
89
- const withState = (stack: CompiledStack<unknown, unknown>, fqns: readonly string[]) =>
76
+ /**
77
+ * Which planned rows plan FROM a resource row the stage's state store holds for them.
78
+ *
79
+ * ⚠️ THE ROW MUST BE THE ONE THE NODE PLANS FROM, NOT MERELY A ROW AT THE SAME FQN. Alchemy lets a
80
+ * rename hand its old id to a new resource in the same deploy (Rename.ts: `Bucket("Assets")
81
+ * .pipe(renamedFrom("Bucket"))` beside a fresh `Bucket("Bucket")`). The store still holds the
82
+ * renamer's row at `Bucket`, so a check by FQN alone called the reuser stateful and the default
83
+ * report hid its `create`. Matching the node's `state.instanceId` also sees the renamer, whose
84
+ * row still sits at its FORMER FQN, as a row without state.
85
+ */
86
+ const withState = (
87
+ stack: CompiledStack<unknown, unknown>,
88
+ nodes: Readonly<Record<string, StatefulNode>>,
89
+ ) =>
90
90
  Effect.gen(function* () {
91
91
  const store = yield* yield* State;
92
92
  const found = yield* Effect.all(
93
- fqns.map((fqn) =>
93
+ Object.entries(nodes).map(([fqn, node]) =>
94
94
  store
95
95
  .get({ fqn, stack: stack.name, stage: stack.stage })
96
- .pipe(Effect.map((row) => (row === undefined || isActionState(row) ? [] : [fqn]))),
96
+ .pipe(
97
+ Effect.map((row) =>
98
+ row === undefined ||
99
+ isActionState(row) ||
100
+ (row as { readonly instanceId?: unknown }).instanceId !== node.state?.instanceId
101
+ ? []
102
+ : [fqn],
103
+ ),
104
+ ),
97
105
  ),
98
106
  { concurrency: 8 },
99
107
  );
@@ -118,7 +126,12 @@ export const verifySession = (
118
126
  >;
119
127
  return Effect.gen(function* () {
120
128
  const planned = yield* plan;
121
- const stateRows = yield* withState(session.stack, Object.keys(planned.resources));
129
+ const stateRows = yield* withState(session.stack, planned.resources);
130
+ // ★ Before the rows are judged: an adopted `noop` beside a differing read is asked again.
131
+ yield* Effect.all(
132
+ Object.entries(planned.resources).map(([fqn, node]) => recheck(fqn, node, seen)),
133
+ { concurrency: 8 },
134
+ );
122
135
  if (all) {
123
136
  const stateful = Object.entries(planned.resources).filter(([fqn]) => stateRows.has(fqn));
124
137
  yield* Effect.all(