@homeflare/alchemy 0.10.0 → 0.11.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.
Files changed (70) hide show
  1. package/dist/caddy/index.js.map +2 -2
  2. package/dist/launchd/index.js.map +2 -2
  3. package/dist/openbao/index.js +10 -8
  4. package/dist/openbao/index.js.map +4 -4
  5. package/dist/ownership/adopting.d.ts +34 -0
  6. package/dist/ownership/adopting.d.ts.map +1 -0
  7. package/dist/ownership/probe.d.ts +15 -5
  8. package/dist/ownership/probe.d.ts.map +1 -1
  9. package/dist/ownership/rows.d.ts +12 -0
  10. package/dist/ownership/rows.d.ts.map +1 -1
  11. package/dist/ownership/store-fixture.d.ts +10 -0
  12. package/dist/ownership/store-fixture.d.ts.map +1 -0
  13. package/dist/proxmox/fake-pve-lxc.d.ts +8 -0
  14. package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -1
  15. package/dist/proxmox/index.js +147 -113
  16. package/dist/proxmox/index.js.map +8 -6
  17. package/dist/proxmox/lxc-adoption.d.ts +32 -0
  18. package/dist/proxmox/lxc-adoption.d.ts.map +1 -0
  19. package/dist/proxmox/lxc-harness.d.ts +12 -0
  20. package/dist/proxmox/lxc-harness.d.ts.map +1 -1
  21. package/dist/proxmox/lxc.d.ts +5 -5
  22. package/dist/proxmox/lxc.d.ts.map +1 -1
  23. package/dist/verify/cli.js +79 -29
  24. package/dist/verify/cli.js.map +8 -6
  25. package/dist/verify/fake-engine.d.ts +9 -1
  26. package/dist/verify/fake-engine.d.ts.map +1 -1
  27. package/dist/verify/index.d.ts +1 -1
  28. package/dist/verify/index.d.ts.map +1 -1
  29. package/dist/verify/index.js +67 -17
  30. package/dist/verify/index.js.map +8 -6
  31. package/dist/verify/plan-scope.d.ts +14 -0
  32. package/dist/verify/plan-scope.d.ts.map +1 -0
  33. package/dist/verify/recheck.d.ts +42 -0
  34. package/dist/verify/recheck.d.ts.map +1 -0
  35. package/dist/verify/rows.d.ts +13 -3
  36. package/dist/verify/rows.d.ts.map +1 -1
  37. package/dist/verify/spy.d.ts +12 -0
  38. package/dist/verify/spy.d.ts.map +1 -1
  39. package/dist/verify/verify.d.ts.map +1 -1
  40. package/docs/adopt-verify.md +13 -2
  41. package/docs/adopted-deploys.md +4 -2
  42. package/docs/ownership.md +1 -1
  43. package/docs/proxmox-lxc-adopt.md +83 -0
  44. package/docs/proxmox-lxc.md +7 -45
  45. package/docs/proxmox.md +2 -0
  46. package/package.json +1 -1
  47. package/src/ownership/adopting.test.ts +63 -0
  48. package/src/ownership/adopting.ts +46 -0
  49. package/src/ownership/ownership.test.ts +1 -34
  50. package/src/ownership/probe.ts +27 -14
  51. package/src/ownership/rows.ts +8 -2
  52. package/src/ownership/store-fixture.ts +50 -0
  53. package/src/proxmox/ceph-pool-adopt.test.ts +32 -0
  54. package/src/proxmox/fake-pve-lxc.ts +13 -1
  55. package/src/proxmox/lxc-adopt.test.ts +13 -20
  56. package/src/proxmox/lxc-adoption.ts +50 -0
  57. package/src/proxmox/lxc-guards.test.ts +6 -13
  58. package/src/proxmox/lxc-harness.ts +25 -2
  59. package/src/proxmox/lxc-ownership.test.ts +19 -14
  60. package/src/proxmox/lxc-strict-adopt.test.ts +104 -0
  61. package/src/proxmox/lxc.ts +16 -12
  62. package/src/verify/fake-engine.ts +24 -0
  63. package/src/verify/index.ts +1 -0
  64. package/src/verify/plan-scope.ts +34 -0
  65. package/src/verify/recheck.test.ts +56 -0
  66. package/src/verify/recheck.ts +71 -0
  67. package/src/verify/rows.ts +46 -5
  68. package/src/verify/spy.ts +14 -3
  69. package/src/verify/verify.test.ts +22 -0
  70. package/src/verify/verify.ts +38 -25
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The strict adoption gate for `Proxmox.Lxc`: AN ADOPTION NEVER CHANGES A GUEST (decided
3
+ * 2026-09-21). A guest this stack adopts — no recorded instance, or one the plan only kept because
4
+ * `--adopt` said so (ownership/adopting.ts) — is taken over exactly as it runs, or not at all.
5
+ *
6
+ * ⛔ ANY DIFFERENCE FAILS, AT PLAN AND AGAIN AT APPLY. Before this, a cold adoption planned `adopted`
7
+ * whatever it would write (alchemy beta.79 prints no property diff for it) and only LOGGED the
8
+ * keys; `deploy --adopt --yes` then wrote them. A `net0` pasted without the live `tag=` moved the
9
+ * guest off its VLAN on the deploy that was meant to change nothing. Now the plan fails naming
10
+ * the keys, and reconcile re-asks against a fresh read, so a hand edit between plan and deploy
11
+ * is refused too rather than written back.
12
+ * ⛔ KEYS ONLY, NEVER VALUES. The refusal names what differs and nothing it differs by: a live
13
+ * config can carry anything (`description` is where people paste credentials), and plan output
14
+ * lands in CI logs.
15
+ * ★ ASKED BEFORE `judge`'s OWN REFUSALS. Those name a declared value (the `pct set` to run), and
16
+ * for an adoption the fix is the other direction: declare what is live.
17
+ * ★ THE STORE IS READ ONLY WHEN SOMETHING DIFFERS. A clean adoption — the only kind that proceeds
18
+ * — costs nothing here.
19
+ */
20
+ import * as Effect from 'effect/Effect';
21
+ import type { Owner } from '../ownership/adopt.ts';
22
+ import { adopting } from '../ownership/adopting.ts';
23
+ import { type LxcChange, judge } from './lxc-judge.ts';
24
+ import { LxcRefusedError } from './lxc-lifecycle.ts';
25
+ import type { LxcProps } from './lxc-props.ts';
26
+
27
+ /** The refusal: keys, where, and what to do — never a value. */
28
+ export const adoptionRefusal = (props: LxcProps, drift: readonly string[]): string =>
29
+ `CT ${String(props.vmid)} on ${props.node}: adopting it would change ${drift.join(', ')}. An ` +
30
+ 'adoption never changes a guest, so nothing is written. Declare what is live for these keys ' +
31
+ '(the plan names keys, never values) and plan again; change them after the adoption, as an ' +
32
+ 'update.';
33
+
34
+ /**
35
+ * Fails with `adoptionRefusal` when `change` would write to a guest `owner`'s call adopts.
36
+ * `settled` for a recovered create is the same judge, of the recording row's props against `live`.
37
+ */
38
+ export const refuseAdoptedDrift = (
39
+ owner: Owner,
40
+ props: LxcProps,
41
+ live: Record<string, unknown>,
42
+ change: LxcChange,
43
+ ): Effect.Effect<void, LxcRefusedError> =>
44
+ Effect.gen(function* () {
45
+ if (change.drift.length === 0) return;
46
+ const settled = (recorded: unknown) =>
47
+ Effect.sync(() => judge(recorded as LxcProps, live).drift.length === 0);
48
+ if (!(yield* adopting(owner, settled))) return;
49
+ return yield* Effect.fail(new LxcRefusedError(adoptionRefusal(props, change.drift)));
50
+ });
@@ -11,19 +11,12 @@
11
11
  import { afterEach, describe, expect, test } from 'bun:test';
12
12
  import { adopt } from 'alchemy/AdoptPolicy';
13
13
  import { type FakePve, fakePve, writesOf } from './fake-pve-lxc.ts';
14
- import { LIVE, TARGET, lxcEngine, seed } from './lxc-harness.ts';
14
+ import { KEY, NODE, TARGET, VMID, adoptedAsIs, lxcEngine, pasted, seed } from './lxc-harness.ts';
15
15
  import { ProxmoxLxc } from './lxc.ts';
16
16
  import type { LxcProps } from './lxc-props.ts';
17
17
 
18
- const NODE = 'pve1';
19
- const VMID = 100;
20
- const KEY = `${NODE}/${String(VMID)}`;
21
18
  const TEMPLATE = 'local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst';
22
-
23
- const pasted = (over: Partial<LxcProps> = {}): LxcProps => {
24
- const { lxc: _raw, ...config } = LIVE;
25
- return { ...(config as Partial<LxcProps>), node: NODE, target: TARGET, vmid: VMID, ...over };
26
- };
19
+ const ct = (props: LxcProps) => ProxmoxLxc('ct', props).pipe(adopt(true));
27
20
 
28
21
  let fake: FakePve | undefined;
29
22
  afterEach(() => {
@@ -113,10 +106,9 @@ describe('a write is claimed only when PVE says it finished', () => {
113
106
  test('a config write PVE accepted but did not store fails the read-back', async () => {
114
107
  const pve = cluster();
115
108
  seed(pve, NODE, VMID);
109
+ const stack = await adoptedAsIs(pve);
116
110
  pve.ignore.add('memory');
117
- const run = await lxcEngine(pve).deploy(
118
- ProxmoxLxc('ct', pasted({ memory: 4096 })).pipe(adopt(true)),
119
- );
111
+ const run = await stack.deploy(ct(pasted({ memory: 4096 })));
120
112
  expect(run.failure).toContain('memory still differ');
121
113
  });
122
114
  });
@@ -137,8 +129,9 @@ describe('identity and volumes', () => {
137
129
  test('options and growth on one volume: the PUT keeps the live size, the resize follows', async () => {
138
130
  const pve = cluster();
139
131
  seed(pve, NODE, VMID);
132
+ const stack = await adoptedAsIs(pve);
140
133
  const mp0 = 'tank:subvol-100-disk-0,mp=/data,backup=1,size=300G';
141
- const run = await lxcEngine(pve).deploy(ProxmoxLxc('ct', pasted({ mp0 })).pipe(adopt(true)));
134
+ const run = await stack.deploy(ct(pasted({ mp0 })));
142
135
  expect(run.failure).toBe('');
143
136
  const path = `nodes/${NODE}/lxc/${String(VMID)}`;
144
137
  expect(writesOf(pve)).toEqual([`PUT ${path}/config`, `PUT ${path}/resize`]);
@@ -13,7 +13,7 @@
13
13
  * ★ ONE ARTIFACT STORE ACROSS A PLAN AND ITS APPLY, and `--adopt` as the AdoptPolicy service, as
14
14
  * openbao/fake-stack.ts provides them: the ownership rule reads both (src/ownership/).
15
15
  */
16
- import { AdoptPolicy } from 'alchemy/AdoptPolicy';
16
+ import { AdoptPolicy, adopt } from 'alchemy/AdoptPolicy';
17
17
  import { apply } from 'alchemy/Apply';
18
18
  import { provideFreshArtifactStore } from 'alchemy/Artifacts';
19
19
  import * as Plan from 'alchemy/Plan';
@@ -29,7 +29,8 @@ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
29
29
  import type { PveTarget } from './credentials.ts';
30
30
  import { withFakeBao } from './fake-bao-env.ts';
31
31
  import { FAKE_BAO, FAKE_MEMBER, type FakePve } from './fake-pve-lxc.ts';
32
- import { ProxmoxLxcProvider } from './lxc.ts';
32
+ import type { LxcProps } from './lxc-props.ts';
33
+ import { ProxmoxLxc, ProxmoxLxcProvider } from './lxc.ts';
33
34
 
34
35
  export const TARGET: PveTarget = {
35
36
  members: [FAKE_MEMBER],
@@ -68,6 +69,17 @@ export const LIVE: Readonly<Record<string, unknown>> = {
68
69
  unprivileged: 1,
69
70
  };
70
71
 
72
+ /** Where `seed` puts the production-shaped guest by default, and its `node/vmid` key. */
73
+ export const NODE = 'pve1';
74
+ export const VMID = 100;
75
+ export const KEY = `${NODE}/${String(VMID)}`;
76
+
77
+ /** The live config as a declaration: every key, minus what pvesh adds that is not a prop. */
78
+ export const pasted = (over: Partial<LxcProps> = {}): LxcProps => {
79
+ const { lxc: _raw, ...config } = LIVE;
80
+ return { ...(config as Partial<LxcProps>), node: NODE, target: TARGET, vmid: VMID, ...over };
81
+ };
82
+
71
83
  /** Put `config` live on the fake as `node/vmid`, with a digest as PVE would add. */
72
84
  export const seed = (fake: FakePve, node: string, vmid: number, config = LIVE) => {
73
85
  fake.guests.set(`${node}/${String(vmid)}`, { ...config, digest: 'digest-live' });
@@ -148,3 +160,14 @@ export const lxcEngine = (fake: FakePve) => {
148
160
  status: (fqn: string) => rows['lxc']?.['test']?.[fqn]?.status,
149
161
  };
150
162
  };
163
+
164
+ /**
165
+ * The engine, with the guest `seed` put at NODE/VMID adopted exactly as it runs — the only adoption
166
+ * that proceeds (lxc-adoption.ts) — so a test can go on to change it, as an ordinary update.
167
+ */
168
+ export const adoptedAsIs = async (pve: FakePve): Promise<ReturnType<typeof lxcEngine>> => {
169
+ const stack = lxcEngine(pve);
170
+ const run = await stack.deploy(ProxmoxLxc('ct', pasted()).pipe(adopt(true)));
171
+ if (run.failure !== '') throw new Error(`the clean adoption failed: ${run.failure}`);
172
+ return stack;
173
+ };
@@ -7,7 +7,8 @@
7
7
  * planned a create refuses a guest it then finds, and forgets its `creating` row; under
8
8
  * `--adopt` it records a matching one with no write and still refuses one that differs; and a
9
9
  * create interrupted after its POST resumes WITHOUT `--adopt` when the guest is what it wrote,
10
- * and asks for `--adopt` when someone changed it meanwhile.
10
+ * and asks for `--adopt` when someone changed it meanwhile — which then adopts it only as it
11
+ * now runs, never writing the declaration back over the change (lxc-adoption.ts).
11
12
  * ★ THE INTERRUPTION IS A CREATE TASK THAT ENDS IN AN ERROR AFTER THE FAKE BUILT THE GUEST — to the
12
13
  * engine the same as a deploy killed while it waited on the task: a live guest, and a
13
14
  * `creating` row with no attributes.
@@ -15,20 +16,12 @@
15
16
  import { afterEach, describe, expect, test } from 'bun:test';
16
17
  import { adopt } from 'alchemy/AdoptPolicy';
17
18
  import { type FakePve, fakePve, writesOf } from './fake-pve-lxc.ts';
18
- import { LIVE, TARGET, lxcEngine, seed } from './lxc-harness.ts';
19
+ import { KEY, NODE, TARGET, VMID, lxcEngine, pasted, seed } from './lxc-harness.ts';
19
20
  import { ProxmoxLxc } from './lxc.ts';
20
21
  import type { LxcProps } from './lxc-props.ts';
21
22
 
22
- const NODE = 'pve1';
23
- const VMID = 100;
24
- const KEY = `${NODE}/${String(VMID)}`;
25
23
  const TAKEOVER = 'already exists, and this stack holds no state for it';
26
24
 
27
- const pasted = (over: Partial<LxcProps> = {}): LxcProps => {
28
- const { lxc: _raw, ...config } = LIVE;
29
- return { ...(config as Partial<LxcProps>), node: NODE, target: TARGET, vmid: VMID, ...over };
30
- };
31
-
32
25
  let fake: FakePve | undefined;
33
26
  afterEach(() => {
34
27
  fake?.stop();
@@ -146,14 +139,26 @@ describe('an interrupted create', () => {
146
139
  expect(stack.status('door')).toBe('created');
147
140
  });
148
141
 
149
- test('asks for --adopt when the guest changed meanwhile, and resumes with it', async () => {
142
+ test('is our own guest, not an adoption: a declaration changed meanwhile is written', async () => {
143
+ const { pve, stack } = await interrupted();
144
+ const run = await stack.deploy(door({ hostname: 'door-renamed' }));
145
+ expect(run.failure).toBe('');
146
+ expect(writesOf(pve)).toEqual([`POST nodes/${NODE}/lxc`, `PUT nodes/${NODE}/lxc/150/config`]);
147
+ expect(pve.guests.get(`${NODE}/150`)?.['hostname']).toBe('door-renamed');
148
+ });
149
+
150
+ test('asks for --adopt when the guest changed meanwhile, and adopts it only as it runs', async () => {
150
151
  const { pve, stack } = await interrupted();
151
152
  const guest = pve.guests.get(`${NODE}/150`) ?? {};
152
153
  guest['hostname'] = 'someone-else';
153
154
  expect((await stack.deploy(door())).failure).toContain('Cannot resume creating');
154
- const resumed = await stack.deploy(door(), { adopt: true });
155
+ const refused = await stack.deploy(door(), { adopt: true });
156
+ expect(refused.failure).toContain('adopting it would change hostname');
157
+ expect(refused.failure).not.toContain('someone-else');
158
+ expect(writesOf(pve)).toEqual([`POST nodes/${NODE}/lxc`]);
159
+ const resumed = await stack.deploy(door({ hostname: 'someone-else' }), { adopt: true });
155
160
  expect(resumed.failure).toBe('');
156
- expect(resumed.warnings.join('\n')).toContain('differs from the declaration in hostname');
157
- expect(pve.guests.get(`${NODE}/150`)?.['hostname']).toBe('door-example');
161
+ expect(writesOf(pve)).toEqual([`POST nodes/${NODE}/lxc`]);
162
+ expect(pve.guests.get(`${NODE}/150`)?.['hostname']).toBe('someone-else');
158
163
  });
159
164
  });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * AN ADOPTION NEVER CHANGES A GUEST (lxc-adoption.ts), through Alchemy's real plan and apply over
3
+ * fake-pve-lxc.ts (placeholders only).
4
+ *
5
+ * ⛔ WHAT THESE PIN: a cold adoption that differs from the live guest in ANY key fails the plan —
6
+ * under `adopt(true)` and under `--adopt` alike — naming the keys and none of the values, before
7
+ * `judge`'s own refusals (which print a declared value); nothing is written and no row is left.
8
+ * A hand edit between the plan and the deploy is refused at apply with nothing written; the
9
+ * adoption row it leaves keeps refusing until the declaration says what is live, and then adopts
10
+ * with no write. An interrupted create that `--adopt` resumes is held to the same rule:
11
+ * lxc-ownership.test.ts.
12
+ * ★ MUTATION-CHECKED 2026-09-21: removing either `refuseAdoptedDrift` call in lxc.ts, or any one of
13
+ * the three adoption answers in ownership/adopting.ts, fails a test here or in
14
+ * lxc-ownership.test.ts.
15
+ */
16
+ import { afterEach, describe, expect, test } from 'bun:test';
17
+ import { adopt } from 'alchemy/AdoptPolicy';
18
+ import { type FakePve, fakePve, writesOf } from './fake-pve-lxc.ts';
19
+ import { KEY, NODE, VMID, lxcEngine, pasted, seed } from './lxc-harness.ts';
20
+ import { ProxmoxLxc } from './lxc.ts';
21
+ import type { LxcProps } from './lxc-props.ts';
22
+
23
+ const REFUSAL = 'adopting it would change';
24
+
25
+ let fake: FakePve | undefined;
26
+ afterEach(() => {
27
+ fake?.stop();
28
+ fake = undefined;
29
+ });
30
+ const live = () => {
31
+ fake = fakePve();
32
+ seed(fake, NODE, VMID);
33
+ return fake;
34
+ };
35
+
36
+ const drifted = (): LxcProps =>
37
+ pasted({ memory: 4096, net1: 'name=eth1,bridge=vmbr1,ip=198.51.100.4/24,mtu=9000' });
38
+
39
+ describe('a cold adoption that differs from the live guest fails the plan', () => {
40
+ test.each([
41
+ ['adopt(true)', () => ProxmoxLxc('ct', drifted()).pipe(adopt(true)), {}],
42
+ ['--adopt', () => ProxmoxLxc('ct', drifted()), { adopt: true }],
43
+ ])('under %s: keys named, values never, nothing written', async (_, declare, options) => {
44
+ const pve = live();
45
+ const stack = lxcEngine(pve);
46
+ const planned = await stack.plan(declare(), options);
47
+ expect(planned.failure).toContain(`CT ${String(VMID)} on ${NODE}: ${REFUSAL} memory, net1.`);
48
+ for (const value of ['4096', '8192', '9000', '1500']) {
49
+ expect(planned.failure).not.toContain(value);
50
+ }
51
+ const deployed = await stack.deploy(declare(), options);
52
+ expect(deployed.failure).toContain(`${REFUSAL} memory, net1`);
53
+ expect(writesOf(pve)).toEqual([]);
54
+ expect(stack.status('ct')).toBeUndefined();
55
+ });
56
+
57
+ test.each([
58
+ ['a device passthrough', { dev1: '/dev/fuse' }, 'dev1', ['/dev/fuse', 'pct set']],
59
+ ['an unprivileged flip', { unprivileged: 0 as const }, 'unprivileged', ['live 1']],
60
+ [
61
+ 'a smaller mount point',
62
+ { mp0: 'tank:subvol-100-disk-0,mp=/data,size=100G' },
63
+ 'mp0',
64
+ ['100G'],
65
+ ],
66
+ ])('%s is the adoption refusal, not the update one', async (_, change, key, hidden) => {
67
+ const pve = live();
68
+ const run = await lxcEngine(pve).plan(ProxmoxLxc('ct', pasted(change)).pipe(adopt(true)));
69
+ expect(run.failure).toContain(`${REFUSAL} ${key}.`);
70
+ for (const value of hidden) expect(run.failure).not.toContain(value);
71
+ expect(writesOf(pve)).toEqual([]);
72
+ });
73
+
74
+ test('an exact match still adopts, with no write and no warning', async () => {
75
+ const pve = live();
76
+ const run = await lxcEngine(pve).deploy(ProxmoxLxc('ct', pasted()).pipe(adopt(true)));
77
+ expect(run).toEqual({ actions: { ct: 'adopted' }, failure: '', warnings: [] });
78
+ expect(writesOf(pve)).toEqual([]);
79
+ });
80
+ });
81
+
82
+ describe('a hand edit between the plan and the deploy', () => {
83
+ test('is refused at apply, and the adoption holds until the declaration says what is live', async () => {
84
+ const pve = live();
85
+ const stack = lxcEngine(pve);
86
+ const declare = (over: Partial<LxcProps> = {}) =>
87
+ ProxmoxLxc('ct', pasted(over)).pipe(adopt(true));
88
+ // ★ Two config reads plan a cold adoption — the probe's and diff's — so the edit lands after
89
+ // the plan said `adopted` and before reconcile reads.
90
+ pve.edits.set(KEY, { after: 2, set: { memory: 4096 } });
91
+ const raced = await stack.deploy(declare());
92
+ expect(raced.failure).toContain(`${REFUSAL} memory.`);
93
+ expect(raced.failure).not.toContain('4096');
94
+ expect(writesOf(pve)).toEqual([]);
95
+ expect(pve.guests.get(KEY)?.['memory']).toBe(4096);
96
+ expect(stack.status('ct')).toBe('updating');
97
+ expect((await stack.plan(declare())).failure).toContain(`${REFUSAL} memory.`);
98
+ const caughtUp = await stack.deploy(declare({ memory: 4096 }));
99
+ expect(caughtUp.failure).toBe('');
100
+ expect(writesOf(pve)).toEqual([]);
101
+ expect(stack.status('ct')).toBe('updated');
102
+ expect((await stack.plan(declare({ memory: 4096 }))).actions).toEqual({ ct: 'noop' });
103
+ });
104
+ });
@@ -17,11 +17,11 @@
17
17
  * ⛔ AND NOTHING HERE WRITES A VOLUME IT DID NOT ALLOCATE. A create takes `storage:GiB` only
18
18
  * (lxc-create-form.ts), and a deploy that planned a create never writes onto a guest it finds.
19
19
  *
20
- * ⚠️ AN ADOPTION ALWAYS PLANS `adopted`, EVEN WHEN IT WOULD WRITE. alchemy 2.0.0-beta.79 turns a
21
- * cold adoption's diff into an update whatever it answered (Plan.ts, `forceUpdateAfterAdoption`)
22
- * and prints no property diff for it. So `diff` LOGS the keys a deploy would write, by name
23
- * (never by value), as a warning — the only place a plan can say the declaration is not the live
24
- * config yet. A clean adoption logs nothing, and its deploy writes nothing (lxc-judge.ts).
20
+ * ⛔ AND AN ADOPTION NEVER CHANGES A GUEST (lxc-adoption.ts). A guest this stack adopts is taken
21
+ * over exactly as it runs: any key the declaration says otherwise FAILS the plan, by name (never
22
+ * by value), and reconcile asks again before it would write. Only an exact match adopts.
23
+ * ⚠️ AN UPDATE STILL PLANS `update` WITHOUT A PROPERTY DIFF, so `diff` LOGS the keys a deploy would
24
+ * write, by name, as a warning — the only place a plan says what the deploy will change.
25
25
  *
26
26
  * ⚠️ POWER STATE IS NOT DECLARED. `onboot` is config; start and stop are an operator's act, as for
27
27
  * `Proxmox.Vm`. `start: 1` starts a guest once, after the create that built it, and never again.
@@ -36,6 +36,7 @@ import { ownedRead } from '../ownership/probe.ts';
36
36
  import { provingResumes } from '../ownership/resume.ts';
37
37
  import { createRefusals } from './lxc-create-form.ts';
38
38
  import { identityRefusals } from './lxc-identity.ts';
39
+ import { refuseAdoptedDrift } from './lxc-adoption.ts';
39
40
  import { judge } from './lxc-judge.ts';
40
41
  import {
41
42
  LxcRefusedError,
@@ -75,8 +76,9 @@ const refused = (reasons: readonly string[]) =>
75
76
  * that follows, so reconcile can tell our own interrupted create from a guest somebody else put
76
77
  * at this vmid.
77
78
  */
78
- const diff = (news: Input<LxcProps>, olds: LxcProps, output: LxcAttributes | undefined) =>
79
+ const diff = (owner: LxcOwner, news: Input<LxcProps>, olds: LxcProps) =>
79
80
  Effect.gen(function* () {
81
+ const { output } = owner;
80
82
  const identity = identityRefusals(news, olds, output);
81
83
  if (identity.length > 0) return yield* refused(identity);
82
84
  if (output === undefined) return undefined;
@@ -96,6 +98,7 @@ const diff = (news: Input<LxcProps>, olds: LxcProps, output: LxcAttributes | und
96
98
  return { action: 'update' } as const;
97
99
  }
98
100
  const change = judge(news, live);
101
+ yield* refuseAdoptedDrift(owner, news, live, change);
99
102
  if (change.refuse.length > 0) return yield* refused(change.refuse);
100
103
  const moved = news.node !== output.node;
101
104
  if (change.drift.length === 0 && !moved) return { action: 'noop' } as const;
@@ -112,7 +115,8 @@ const diff = (news: Input<LxcProps>, olds: LxcProps, output: LxcAttributes | und
112
115
  /**
113
116
  * ⛔ THE SAME JUDGE AS `diff`, RE-RUN AGAINST A FRESH READ, AND AN EMPTY CHANGE WRITES NOTHING.
114
117
  * Apply routes `adopted` through reconcile (resource.ts has the Apply.ts lines), so this is what
115
- * runs on the first deploy after an adoption; for a guest that already matches it is two GETs.
118
+ * runs on the first deploy after an adoption; for a guest that already matches it is two GETs,
119
+ * and for one edited since the plan it is a refusal, never a write (lxc-adoption.ts).
116
120
  * ⛔ AND IT READS BACK AND JUDGES AGAIN. PVE answers 200 on writes that did nothing, so a change
117
121
  * that did not land is a failure here, not a state row claiming it did. ⚠️ A change PVE parked
118
122
  * as pending on a running guest DOES read back (GET config merges `[pending]` unless asked for
@@ -148,6 +152,7 @@ const reconcile = (owner: LxcOwner, news: LxcProps) =>
148
152
  'onto an existing guest: plan again, so the plan can show it and adopt it.',
149
153
  ]);
150
154
  }
155
+ yield* refuseAdoptedDrift(owner, news, live, change);
151
156
  if (change.refuse.length > 0) return yield* refused(change.refuse);
152
157
  if (change.drift.length > 0) yield* updateGuest(news, change, text(live['digest']));
153
158
  }
@@ -208,14 +213,13 @@ const handlers = {
208
213
  delete: ({ olds, output }: { olds: LxcProps; output: LxcAttributes }) =>
209
214
  destroyGuest(whereOf(olds, output), olds),
210
215
  diff: ({
216
+ fqn,
217
+ instanceId,
211
218
  news,
212
219
  olds,
213
220
  output,
214
- }: {
215
- news: Input<LxcProps>;
216
- olds: LxcProps;
217
- output: LxcAttributes | undefined;
218
- }) => diff(news, olds, output),
221
+ }: Asked & { news: Input<LxcProps>; olds: LxcProps; output: LxcAttributes | undefined }) =>
222
+ diff({ fqn, instanceId, output }, news, olds),
219
223
  list: () => Effect.succeed([]),
220
224
  read: ({
221
225
  fqn,
@@ -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
+ });