@homeflare/alchemy 0.8.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.
Files changed (247) hide show
  1. package/README.md +22 -11
  2. package/dist/caddy/config-lifecycle.d.ts +1 -1
  3. package/dist/caddy/config.d.ts.map +1 -1
  4. package/dist/caddy/index.js +221 -53
  5. package/dist/caddy/index.js.map +13 -9
  6. package/dist/launchd/fake-runner.d.ts +1 -0
  7. package/dist/launchd/fake-runner.d.ts.map +1 -1
  8. package/dist/launchd/fake-sudo.d.ts +1 -0
  9. package/dist/launchd/fake-sudo.d.ts.map +1 -1
  10. package/dist/launchd/host-effect.d.ts +9 -18
  11. package/dist/launchd/host-effect.d.ts.map +1 -1
  12. package/dist/launchd/host-file-lifecycle.d.ts +14 -1
  13. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  14. package/dist/launchd/host-file.d.ts +2 -1
  15. package/dist/launchd/host-file.d.ts.map +1 -1
  16. package/dist/launchd/index.js +345 -86
  17. package/dist/launchd/index.js.map +18 -12
  18. package/dist/launchd/job-lifecycle.d.ts +8 -1
  19. package/dist/launchd/job-lifecycle.d.ts.map +1 -1
  20. package/dist/launchd/job-preflight.d.ts +13 -1
  21. package/dist/launchd/job-preflight.d.ts.map +1 -1
  22. package/dist/launchd/job.d.ts +2 -1
  23. package/dist/launchd/job.d.ts.map +1 -1
  24. package/dist/launchd/runner.d.ts +7 -0
  25. package/dist/launchd/runner.d.ts.map +1 -1
  26. package/dist/launchd/sudo-acl.d.ts +25 -0
  27. package/dist/launchd/sudo-acl.d.ts.map +1 -0
  28. package/dist/launchd/sudo-guard.d.ts +31 -4
  29. package/dist/launchd/sudo-guard.d.ts.map +1 -1
  30. package/dist/launchd/sudo-runner.d.ts +3 -3
  31. package/dist/launchd/sudo-runner.d.ts.map +1 -1
  32. package/dist/launchd/sudo-said.d.ts +10 -0
  33. package/dist/launchd/sudo-said.d.ts.map +1 -0
  34. package/dist/openbao/auth-method-reconcile.d.ts +6 -2
  35. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -1
  36. package/dist/openbao/auth-method.d.ts.map +1 -1
  37. package/dist/openbao/auth-role.d.ts.map +1 -1
  38. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  39. package/dist/openbao/fake-bao.d.ts +18 -0
  40. package/dist/openbao/fake-bao.d.ts.map +1 -1
  41. package/dist/openbao/fake-core.d.ts +39 -0
  42. package/dist/openbao/fake-core.d.ts.map +1 -0
  43. package/dist/openbao/fake-families.d.ts +6 -3
  44. package/dist/openbao/fake-families.d.ts.map +1 -1
  45. package/dist/openbao/fake-mounts.d.ts +16 -0
  46. package/dist/openbao/fake-mounts.d.ts.map +1 -0
  47. package/dist/openbao/fake-stack.d.ts +5 -1
  48. package/dist/openbao/fake-stack.d.ts.map +1 -1
  49. package/dist/openbao/index.js +541 -270
  50. package/dist/openbao/index.js.map +26 -21
  51. package/dist/openbao/jwt-config.d.ts.map +1 -1
  52. package/dist/openbao/jwt-role.d.ts.map +1 -1
  53. package/dist/openbao/kubernetes-role.d.ts.map +1 -1
  54. package/dist/openbao/mfa-enforcement.d.ts +6 -2
  55. package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
  56. package/dist/openbao/mfa-totp.d.ts +6 -2
  57. package/dist/openbao/mfa-totp.d.ts.map +1 -1
  58. package/dist/openbao/mount-reconcile.d.ts +5 -1
  59. package/dist/openbao/mount-reconcile.d.ts.map +1 -1
  60. package/dist/openbao/mount.d.ts.map +1 -1
  61. package/dist/openbao/pki-role.d.ts.map +1 -1
  62. package/dist/openbao/plugin-reconcile.d.ts +6 -2
  63. package/dist/openbao/plugin-reconcile.d.ts.map +1 -1
  64. package/dist/openbao/plugin.d.ts.map +1 -1
  65. package/dist/openbao/policy.d.ts.map +1 -1
  66. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  67. package/dist/openbao/role-reconcile.d.ts +11 -1
  68. package/dist/openbao/role-reconcile.d.ts.map +1 -1
  69. package/dist/openbao/ssh-role.d.ts.map +1 -1
  70. package/dist/ownership/adopt.d.ts +52 -0
  71. package/dist/ownership/adopt.d.ts.map +1 -0
  72. package/dist/ownership/probe.d.ts +13 -0
  73. package/dist/ownership/probe.d.ts.map +1 -0
  74. package/dist/ownership/resume.d.ts +40 -0
  75. package/dist/ownership/resume.d.ts.map +1 -0
  76. package/dist/ownership/rows.d.ts +33 -0
  77. package/dist/ownership/rows.d.ts.map +1 -0
  78. package/dist/ownership/whole.d.ts +14 -0
  79. package/dist/ownership/whole.d.ts.map +1 -0
  80. package/dist/proxmox/ceph-pool-form.d.ts +3 -3
  81. package/dist/proxmox/ceph-pool.d.ts.map +1 -1
  82. package/dist/proxmox/fake-bao-env.d.ts +3 -0
  83. package/dist/proxmox/fake-bao-env.d.ts.map +1 -0
  84. package/dist/proxmox/fake-pve-lxc.d.ts +42 -0
  85. package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -0
  86. package/dist/proxmox/fake-pve.d.ts +38 -0
  87. package/dist/proxmox/fake-pve.d.ts.map +1 -0
  88. package/dist/proxmox/index.d.ts +6 -2
  89. package/dist/proxmox/index.d.ts.map +1 -1
  90. package/dist/proxmox/index.js +970 -186
  91. package/dist/proxmox/index.js.map +22 -9
  92. package/dist/proxmox/lxc-create-form.d.ts +6 -0
  93. package/dist/proxmox/lxc-create-form.d.ts.map +1 -0
  94. package/dist/proxmox/lxc-harness.d.ts +34 -0
  95. package/dist/proxmox/lxc-harness.d.ts.map +1 -0
  96. package/dist/proxmox/lxc-identity.d.ts +18 -0
  97. package/dist/proxmox/lxc-identity.d.ts.map +1 -0
  98. package/dist/proxmox/lxc-judge.d.ts +46 -0
  99. package/dist/proxmox/lxc-judge.d.ts.map +1 -0
  100. package/dist/proxmox/lxc-lifecycle.d.ts +68 -0
  101. package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -0
  102. package/dist/proxmox/lxc-props.d.ts +139 -0
  103. package/dist/proxmox/lxc-props.d.ts.map +1 -0
  104. package/dist/proxmox/lxc-volume.d.ts +29 -0
  105. package/dist/proxmox/lxc-volume.d.ts.map +1 -0
  106. package/dist/proxmox/lxc-wire.d.ts +51 -0
  107. package/dist/proxmox/lxc-wire.d.ts.map +1 -0
  108. package/dist/proxmox/lxc.d.ts +27 -47
  109. package/dist/proxmox/lxc.d.ts.map +1 -1
  110. package/dist/proxmox/network-apply-read.d.ts +1 -1
  111. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  112. package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
  113. package/dist/proxmox/resource.d.ts.map +1 -1
  114. package/dist/proxmox/update-guard.d.ts +27 -0
  115. package/dist/proxmox/update-guard.d.ts.map +1 -0
  116. package/dist/verify/args.d.ts +16 -0
  117. package/dist/verify/args.d.ts.map +1 -0
  118. package/dist/verify/cli.d.ts +3 -0
  119. package/dist/verify/cli.d.ts.map +1 -0
  120. package/dist/verify/cli.js +370 -0
  121. package/dist/verify/cli.js.map +17 -0
  122. package/dist/verify/fake-engine.d.ts +65 -0
  123. package/dist/verify/fake-engine.d.ts.map +1 -0
  124. package/dist/verify/index.d.ts +16 -0
  125. package/dist/verify/index.d.ts.map +1 -0
  126. package/dist/verify/index.js +347 -0
  127. package/dist/verify/index.js.map +16 -0
  128. package/dist/verify/plan-scope.d.ts +14 -0
  129. package/dist/verify/plan-scope.d.ts.map +1 -0
  130. package/dist/verify/recheck.d.ts +42 -0
  131. package/dist/verify/recheck.d.ts.map +1 -0
  132. package/dist/verify/report.d.ts +6 -0
  133. package/dist/verify/report.d.ts.map +1 -0
  134. package/dist/verify/rows.d.ts +67 -0
  135. package/dist/verify/rows.d.ts.map +1 -0
  136. package/dist/verify/spy.d.ts +43 -0
  137. package/dist/verify/spy.d.ts.map +1 -0
  138. package/dist/verify/verify.d.ts +43 -0
  139. package/dist/verify/verify.d.ts.map +1 -0
  140. package/docs/adopt-verify.md +119 -0
  141. package/docs/adopted-deploys.md +85 -0
  142. package/docs/credentials.md +15 -0
  143. package/docs/launchd-sudo.md +31 -6
  144. package/docs/launchd.md +7 -3
  145. package/docs/ownership.md +113 -0
  146. package/docs/proxmox-lxc.md +196 -0
  147. package/docs/proxmox.md +12 -0
  148. package/package.json +9 -2
  149. package/src/caddy/adopt-scope.test.ts +1 -1
  150. package/src/caddy/config-lifecycle.ts +1 -1
  151. package/src/caddy/config.ts +2 -1
  152. package/src/launchd/adopt-apply.test.ts +166 -0
  153. package/src/launchd/fake-runner.ts +26 -4
  154. package/src/launchd/host-effect.ts +1 -33
  155. package/src/launchd/host-file-lifecycle.ts +39 -14
  156. package/src/launchd/host-file.ts +13 -4
  157. package/src/launchd/job-lifecycle.ts +22 -14
  158. package/src/launchd/job-preflight.ts +18 -1
  159. package/src/launchd/job.ts +13 -4
  160. package/src/launchd/runner.ts +7 -0
  161. package/src/launchd/sudo-acl.ts +68 -0
  162. package/src/launchd/sudo-chain.test.ts +121 -0
  163. package/src/launchd/sudo-guard.ts +89 -10
  164. package/src/launchd/sudo-modes.test.ts +135 -0
  165. package/src/launchd/sudo-runner.ts +40 -59
  166. package/src/launchd/sudo-said.ts +58 -0
  167. package/src/openbao/README.md +5 -2
  168. package/src/openbao/REPLACE.md +13 -11
  169. package/src/openbao/adopt-core.test.ts +119 -0
  170. package/src/openbao/adopt-families.test.ts +97 -0
  171. package/src/openbao/adopt-holes.test.ts +168 -0
  172. package/src/openbao/adopt-recovery.test.ts +71 -0
  173. package/src/openbao/auth-method-reconcile.ts +15 -3
  174. package/src/openbao/auth-method.ts +15 -6
  175. package/src/openbao/auth-role.ts +14 -4
  176. package/src/openbao/cloudflare-role.ts +17 -4
  177. package/src/openbao/fake-bao.ts +52 -0
  178. package/src/openbao/fake-core.ts +141 -0
  179. package/src/openbao/fake-families.ts +11 -5
  180. package/src/openbao/fake-mounts.ts +74 -0
  181. package/src/openbao/fake-stack.ts +17 -4
  182. package/src/openbao/jwt-config.ts +10 -7
  183. package/src/openbao/jwt-role.ts +10 -7
  184. package/src/openbao/kubernetes-role.ts +13 -7
  185. package/src/openbao/mfa-enforcement.ts +20 -6
  186. package/src/openbao/mfa-totp.ts +22 -7
  187. package/src/openbao/mount-move.test.ts +2 -35
  188. package/src/openbao/mount-reconcile.ts +13 -2
  189. package/src/openbao/mount.ts +15 -6
  190. package/src/openbao/pki-role.ts +15 -4
  191. package/src/openbao/plugin-reconcile.ts +16 -5
  192. package/src/openbao/plugin.ts +17 -6
  193. package/src/openbao/policy.ts +17 -4
  194. package/src/openbao/proxmox-role.ts +13 -4
  195. package/src/openbao/rename-adoption.test.ts +32 -19
  196. package/src/openbao/role-reconcile.ts +23 -0
  197. package/src/openbao/ssh-role.ts +14 -4
  198. package/src/ownership/adopt.ts +122 -0
  199. package/src/ownership/ownership.test.ts +200 -0
  200. package/src/ownership/probe.ts +65 -0
  201. package/src/ownership/resume.ts +118 -0
  202. package/src/ownership/rows.ts +117 -0
  203. package/src/ownership/whole.test.ts +39 -0
  204. package/src/ownership/whole.ts +60 -0
  205. package/src/proxmox/adopt-noop.test.ts +112 -0
  206. package/src/proxmox/ceph-pool-adopt.test.ts +120 -0
  207. package/src/proxmox/ceph-pool-form.ts +3 -3
  208. package/src/proxmox/ceph-pool.ts +10 -3
  209. package/src/proxmox/fake-bao-env.ts +28 -0
  210. package/src/proxmox/fake-pve-lxc.ts +210 -0
  211. package/src/proxmox/fake-pve.ts +75 -0
  212. package/src/proxmox/index.test.ts +6 -6
  213. package/src/proxmox/index.ts +6 -2
  214. package/src/proxmox/lxc-adopt.test.ts +170 -0
  215. package/src/proxmox/lxc-create-form.ts +76 -0
  216. package/src/proxmox/lxc-create.test.ts +126 -0
  217. package/src/proxmox/lxc-guards.test.ts +158 -0
  218. package/src/proxmox/lxc-harness.ts +150 -0
  219. package/src/proxmox/lxc-identity.ts +43 -0
  220. package/src/proxmox/lxc-judge.test.ts +175 -0
  221. package/src/proxmox/lxc-judge.ts +196 -0
  222. package/src/proxmox/lxc-lifecycle.ts +200 -0
  223. package/src/proxmox/lxc-ownership.test.ts +159 -0
  224. package/src/proxmox/lxc-props.ts +215 -0
  225. package/src/proxmox/lxc-read.test.ts +60 -0
  226. package/src/proxmox/lxc-volume.test.ts +96 -0
  227. package/src/proxmox/lxc-volume.ts +153 -0
  228. package/src/proxmox/lxc-wire.ts +210 -0
  229. package/src/proxmox/lxc.ts +216 -93
  230. package/src/proxmox/network-apply-read.ts +3 -1
  231. package/src/proxmox/pbs-datastore.ts +4 -3
  232. package/src/proxmox/resource.ts +7 -7
  233. package/src/proxmox/update-guard.ts +31 -0
  234. package/src/verify/args.ts +82 -0
  235. package/src/verify/cli.test.ts +86 -0
  236. package/src/verify/cli.ts +49 -0
  237. package/src/verify/fake-engine.ts +182 -0
  238. package/src/verify/index.ts +37 -0
  239. package/src/verify/plan-scope.ts +34 -0
  240. package/src/verify/recheck.test.ts +56 -0
  241. package/src/verify/recheck.ts +71 -0
  242. package/src/verify/report.ts +52 -0
  243. package/src/verify/rows.ts +210 -0
  244. package/src/verify/spy.test.ts +141 -0
  245. package/src/verify/spy.ts +160 -0
  246. package/src/verify/verify.test.ts +146 -0
  247. package/src/verify/verify.ts +185 -0
@@ -0,0 +1,200 @@
1
+ /**
2
+ * The engine-facing edges of ownership/: which instance a read is about, whether its row carries
3
+ * the whole declaration, what a resume note is worth without a shared bag or a proof, and that a
4
+ * `settled` check that cannot run never fails the plan. whole.test.ts has the hole walk itself;
5
+ * the families' end-to-end behaviour is pinned through the engine in openbao/adopt-*.test.ts.
6
+ */
7
+ import { describe, expect, test } from 'bun:test';
8
+ import { AdoptPolicy, Unowned } from 'alchemy/AdoptPolicy';
9
+ import { Artifacts, makeScopedArtifacts } from 'alchemy/Artifacts';
10
+ import { InMemoryService } from 'alchemy/State/InMemoryState';
11
+ import type { ResourceState } from 'alchemy/State/ResourceState';
12
+ import { State } from 'alchemy/State/State';
13
+ import { Stack } from 'alchemy/Stack';
14
+ import * as Effect from 'effect/Effect';
15
+ import { refuseTakeover } from './adopt.ts';
16
+ import { ownedRead } from './probe.ts';
17
+ import { noteResume, noteUnfinished, resumes } from './resume.ts';
18
+ import { forgetRefusedCreate, isCreate, recordedInstance } from './rows.ts';
19
+
20
+ const row = (fqn: string, instanceId: string, old?: unknown, props: unknown = { name: 'a' }) =>
21
+ ({
22
+ fqn,
23
+ instanceId,
24
+ old,
25
+ props,
26
+ status: old === undefined ? 'creating' : 'replacing',
27
+ }) as unknown as ResourceState;
28
+
29
+ /**
30
+ * A stack `s` at stage `test` whose store holds `rows`: `A` and `B` declared with a `name`, and `Z`
31
+ * declared `renamedFrom('X')`.
32
+ */
33
+ const withStore = <A>(rows: Record<string, ResourceState>, effect: Effect.Effect<A>): Promise<A> =>
34
+ Effect.runPromise(
35
+ effect.pipe(
36
+ Effect.provideService(State, InMemoryService({ s: { test: rows } })),
37
+ Effect.provideService(Stack, {
38
+ actions: {},
39
+ bindings: {},
40
+ name: 's',
41
+ resources: {
42
+ A: { Props: { name: 'a' } },
43
+ B: { Props: { name: 'b' } },
44
+ Z: { FormerFqns: ['X'], Props: { name: 'a' } },
45
+ },
46
+ stage: 'test',
47
+ } as never),
48
+ ),
49
+ );
50
+
51
+ describe('recordedInstance', () => {
52
+ test('is the row at the FQN, any generation of its `old` chain, or a renamedFrom row', async () => {
53
+ const rows = { A: row('A', 'new', row('A', 'older')), X: row('X', 'moved') };
54
+ expect(await withStore(rows, recordedInstance('A', 'new'))).toBe('whole');
55
+ expect(await withStore(rows, recordedInstance('A', 'older'))).toBe('whole');
56
+ expect(await withStore(rows, recordedInstance('Z', 'moved'))).toBe('whole');
57
+ });
58
+
59
+ test("the probe's freshly minted instance is never recorded", async () => {
60
+ expect(await withStore({ A: row('A', 'mine') }, recordedInstance('A', 'probe'))).toBe('absent');
61
+ expect(await withStore({}, recordedInstance('A', 'mine'))).toBe('absent');
62
+ });
63
+
64
+ test('a row missing a declared prop (an Output stripped at commit) is partial', async () => {
65
+ const holed = { A: row('A', 'mine', undefined, {}) };
66
+ expect(await withStore(holed, recordedInstance('A', 'mine'))).toBe('partial');
67
+ // ★ No longer declared (an orphan's delete): nothing to compare, so never whole.
68
+ expect(await withStore({ C: row('C', 'mine') }, recordedInstance('C', 'mine'))).toBe('partial');
69
+ });
70
+
71
+ test('fails closed with no Stack or State in context', async () => {
72
+ expect(await Effect.runPromise(recordedInstance('A', 'mine'))).toBe('absent');
73
+ });
74
+ });
75
+
76
+ describe('isCreate', () => {
77
+ test("is this instance's `creating` row — never a replace's `replacing` one", async () => {
78
+ const rows = { A: row('A', 'mine'), B: row('B', 'new', row('B', 'older')) };
79
+ expect(await withStore(rows, isCreate('A', 'mine'))).toBe(true);
80
+ expect(await withStore(rows, isCreate('B', 'new'))).toBe(false);
81
+ expect(await withStore(rows, isCreate('A', 'other'))).toBe(false);
82
+ expect(await Effect.runPromise(isCreate('A', 'mine'))).toBe(false);
83
+ });
84
+ });
85
+
86
+ describe('forgetRefusedCreate', () => {
87
+ test('drops only the creating row of this instance, with no attributes', async () => {
88
+ const rows: Record<string, ResourceState> = {
89
+ A: row('A', 'mine'),
90
+ B: row('B', 'mine', row('B', 'older')),
91
+ C: row('C', 'theirs'),
92
+ D: { ...row('D', 'mine'), attr: { name: 'd' } },
93
+ };
94
+ for (const fqn of ['A', 'B', 'C', 'D']) await withStore(rows, forgetRefusedCreate(fqn, 'mine'));
95
+ expect(Object.keys(rows).sort()).toEqual(['B', 'C', 'D']);
96
+ });
97
+ });
98
+
99
+ describe('ownedRead', () => {
100
+ const found = { name: 'a' };
101
+ const ask = (output?: unknown) => ({ fqn: 'A', instanceId: 'mine', output });
102
+ const rows = { A: row('A', 'mine') };
103
+
104
+ test('with state it is the object as found; absent is absent', async () => {
105
+ const plain = await Effect.runPromise(ownedRead(ask({}), found, Effect.succeed(false)));
106
+ expect(Unowned.is(plain)).toBe(false);
107
+ expect(await Effect.runPromise(ownedRead(ask(), undefined, Effect.succeed(true)))).toBe(
108
+ undefined,
109
+ );
110
+ });
111
+
112
+ test('the probe answers Unowned even when the object is settled', async () => {
113
+ const probed = await withStore({}, ownedRead(ask(), found, Effect.succeed(true)));
114
+ expect(Unowned.is(probed)).toBe(true);
115
+ });
116
+
117
+ test('the recovery read is ours only when settled, over a row with the whole declaration', async () => {
118
+ expect(Unowned.is(await withStore(rows, ownedRead(ask(), found, Effect.succeed(true))))).toBe(
119
+ false,
120
+ );
121
+ expect(Unowned.is(await withStore(rows, ownedRead(ask(), found, Effect.succeed(false))))).toBe(
122
+ true,
123
+ );
124
+ const holed = { A: row('A', 'mine', undefined, {}) };
125
+ expect(Unowned.is(await withStore(holed, ownedRead(ask(), found, Effect.succeed(true))))).toBe(
126
+ true,
127
+ );
128
+ });
129
+
130
+ test('a settled check that fails or dies is Unowned, never a failed plan', async () => {
131
+ const failing = Effect.fail(new Error('fragments directory moved'));
132
+ const dying = Effect.die(new TypeError('olds.tokenPolicies is undefined'));
133
+ expect(Unowned.is(await withStore(rows, ownedRead(ask(), found, failing)))).toBe(true);
134
+ expect(Unowned.is(await withStore(rows, ownedRead(ask(), found, dying)))).toBe(true);
135
+ });
136
+ });
137
+
138
+ describe('resume notes and refuseTakeover', () => {
139
+ const bag = makeScopedArtifacts(new Map(), 'A');
140
+ const inBag = <A>(effect: Effect.Effect<A>) =>
141
+ Effect.runPromise(effect.pipe(Effect.provideService(Artifacts, bag)));
142
+
143
+ const ours = Effect.succeed({ name: 'a' });
144
+
145
+ test('a note is read back for its own instance only, and nothing is noted without a bag', async () => {
146
+ await inBag(noteResume('mine', ours));
147
+ expect(await inBag(resumes('mine'))).toBe(true);
148
+ expect(await inBag(resumes('other'))).toBe(false);
149
+ await Effect.runPromise(noteResume('mine', ours));
150
+ expect(await Effect.runPromise(resumes('mine'))).toBe(false);
151
+ });
152
+
153
+ test('nothing is noted unless the read proves the object ours', async () => {
154
+ const fresh = makeScopedArtifacts(new Map(), 'A');
155
+ const unproven = [
156
+ Effect.succeed(undefined),
157
+ Effect.succeed(Unowned({ name: 'a' })),
158
+ Effect.fail(new Error('403')),
159
+ Effect.die(new TypeError('olds.name is undefined')),
160
+ ];
161
+ for (const read of unproven) {
162
+ await Effect.runPromise(
163
+ noteResume('mine', read).pipe(Effect.provideService(Artifacts, fresh)),
164
+ );
165
+ }
166
+ expect(
167
+ await Effect.runPromise(resumes('mine').pipe(Effect.provideService(Artifacts, fresh))),
168
+ ).toBe(false);
169
+ });
170
+
171
+ test('refuses a create with no note and no adoption; state or a note lets it through', async () => {
172
+ const owner = { fqn: 'A', instanceId: 'mine', output: undefined };
173
+ await expect(Effect.runPromise(refuseTakeover(owner, 'Thing a'))).rejects.toThrow(
174
+ /Thing a: already exists.*--adopt/s,
175
+ );
176
+ await Effect.runPromise(refuseTakeover({ ...owner, output: {} }, 'Thing a'));
177
+ await inBag(noteResume('mine', ours));
178
+ await inBag(refuseTakeover(owner, 'Thing a'));
179
+ });
180
+
181
+ test("--adopt covers a create or an unfinished generation, never a fresh replace's", async () => {
182
+ const rows = { A: row('A', 'mine'), B: row('B', 'new', row('B', 'older')) };
183
+ const adopting = <A>(effect: Effect.Effect<A>, bag = makeScopedArtifacts(new Map(), 'B')) =>
184
+ withStore(
185
+ rows,
186
+ effect.pipe(
187
+ Effect.provideService(AdoptPolicy, true),
188
+ Effect.provideService(Artifacts, bag),
189
+ ),
190
+ );
191
+ await adopting(refuseTakeover({ fqn: 'A', instanceId: 'mine', output: undefined }, 'Thing a'));
192
+ const fresh = { fqn: 'B', instanceId: 'new', output: undefined };
193
+ await expect(adopting(refuseTakeover(fresh, 'Thing b'))).rejects.toThrow(
194
+ /Thing b: already exists.*new identity of a replace, which --adopt does not cover/s,
195
+ );
196
+ const seen = makeScopedArtifacts(new Map(), 'B');
197
+ await adopting(noteUnfinished('new'), seen);
198
+ await adopting(refuseTakeover(fresh, 'Thing b'), seen);
199
+ });
200
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `read` with no attributes — the plan-time half of "nothing is adopted without `--adopt`"
3
+ * (adopt.ts has the apply-time half). Alchemy asks it in two situations and passes `output:
4
+ * undefined` in both (alchemy beta.79 Plan.ts):
5
+ * · THE ADOPTION PROBE — no state row at all; `olds` is the declaration, `instanceId` fresh.
6
+ * · THE RECOVERY READ — a `creating` row whose create was interrupted after its write and before
7
+ * its commit; `olds` is that row's props, `instanceId` the row's. (Apply.ts asks the same for a
8
+ * row it must delete that never recorded attributes.)
9
+ * The answer routes the plan: `undefined` → create; plain attributes → ours; `Unowned(…)` → the
10
+ * plan fails with OwnedBySomeoneElse unless `--adopt` / `adopt(true)` (AdoptPolicy.ts).
11
+ *
12
+ * ⛔ THE PROBE ALWAYS ANSWERS `Unowned` FOR A LIVE OBJECT, EVEN ONE IDENTICAL TO THE DECLARATION
13
+ * (decided 2026-09-21). Identical is not ours: a name another stack, a person or an old script put
14
+ * there reads the same, and once state claims it, a delete under `destroy` removes it from its
15
+ * real owner. 🔴 Measured before the change (openbao/rename-adoption.test.ts): a new logical id
16
+ * for a live Bao name adopted it silently, and the old id's delete then removed it, green.
17
+ * ★ THE RECOVERY READ STILL ADOPTS WHAT THE INTERRUPTED CREATE MADE — the instance is ours by the
18
+ * state row, and the object is proven ours when it matches that row's props (`settled`). One that
19
+ * does not match may have lost a race to someone else: `Unowned`, as Plan.ts itself asks.
20
+ * ⛔ ONLY WHEN THE ROW'S PROPS ARE THE WHOLE DECLARATION (whole.ts). A row written while a prop was
21
+ * still an Output has a hole there, and "matches" against a hole proves nothing — nor had that
22
+ * deploy's plan ever asked whose object it was, because Alchemy skips the probe for an Output.
23
+ * Such a create resumes with `--adopt`.
24
+ */
25
+ import { Unowned } from 'alchemy/AdoptPolicy';
26
+ import * as Effect from 'effect/Effect';
27
+ import type { Owner } from './adopt.ts';
28
+ import { recordedInstance } from './rows.ts';
29
+
30
+ /**
31
+ * A family's `read` answer. With attributes in state it is `found` as is. Without, it is the probe
32
+ * or the recovery read (header): `found` is ours only when the state store records this instance
33
+ * with its whole declaration AND `settled` — the family's own "live matches these props" — says so.
34
+ * ⛔ `settled` NEVER FAILS THE READ. The recovery read runs with the INTERRUPTED deploy's props, so
35
+ * a declaration that cannot be evaluated any more (a fragments directory since moved, a group
36
+ * name since renamed) would fail every later plan, the fix included. It reads as "not proven
37
+ * ours" instead — `Unowned`, which `--adopt` resolves — with the reason logged.
38
+ */
39
+ export const ownedRead = <A extends object, E, R>(
40
+ ask: Owner,
41
+ found: A | undefined,
42
+ settled: Effect.Effect<boolean, E, R>,
43
+ ): Effect.Effect<A | undefined, never, R> =>
44
+ Effect.gen(function* () {
45
+ if (found === undefined || ask.output !== undefined) return found;
46
+ const recorded = yield* recordedInstance(ask.fqn, ask.instanceId);
47
+ if (recorded === 'absent') return Unowned(found);
48
+ if (recorded === 'partial') {
49
+ yield* Effect.logWarning(
50
+ `${ask.fqn}: the interrupted create's state row lacks part of the declaration (a prop ` +
51
+ 'was still an Output when it was written), so the live object is not proven ours',
52
+ );
53
+ return Unowned(found);
54
+ }
55
+ const unproven = (reason: unknown) =>
56
+ Effect.as(
57
+ Effect.logWarning(
58
+ `${ask.fqn}: the interrupted create's object could not be compared with its props ` +
59
+ `(${reason instanceof Error ? reason.message : String(reason)}), so it is not treated as ours`,
60
+ ),
61
+ false,
62
+ );
63
+ const ours = yield* settled.pipe(Effect.catch(unproven), Effect.catchDefect(unproven));
64
+ return ours ? found : Unowned(found);
65
+ });
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Whether this apply RESUMES a create or replace the engine already started for this resource AND
3
+ * the live object it will find is proven that generation's own — the one case in which a stateless
4
+ * `reconcile` may write over a live object without `--adopt`.
5
+ *
6
+ * ★ HOW THE ENGINE TELLS US. Alchemy calls `diff` whenever a state row exists, and hands it `output`
7
+ * = that row's attributes (alchemy beta.79 Plan.ts). A row with NO attributes is exactly an
8
+ * unfinished generation: `creating` (a create interrupted after its write, before its commit),
9
+ * `replacing` (the same for a replace), or `deleting` re-driven as a create. A fresh create is
10
+ * never diffed at all. So the diff of such a row decides, and the apply that follows (the same
11
+ * instance id: Apply.ts reuses the row's) finds the note.
12
+ * ⛔ A ROW PROVES NOTHING BY ITSELF (red team, 2026-09-21). Apply commits it BEFORE reconcile runs, so
13
+ * a deploy killed before the claim — an upstream that failed, a Ctrl-C — leaves a row whose create
14
+ * never asked whose object sat at its identity. 🔴 MEASURED (openbao/adopt-holes.test.ts): with the
15
+ * name an Output, the next deploy "resumed" that create and wrote over another owner's role, in
16
+ * every family that had a note on the row alone. So the note is left only when the family's own
17
+ * `read`, asked as the recovery read (probe.ts ownedRead: a recorded instance, a row carrying the
18
+ * whole declaration, and a live object that matches it), answers "ours". For a `creating` row
19
+ * that is the question Plan.ts already asked; for a `replacing` row it is the only time it is asked.
20
+ * ⚠️ WHY NOT THE STATE STORE ALONE. At reconcile both look the same: Apply commits the fresh create's
21
+ * `creating` row before calling reconcile, so a row with this instance id is always there.
22
+ * ★ THE CHANNEL IS ALCHEMY'S OWN: `Artifacts`, the per-FQN bag the engine shares between one plan
23
+ * and its apply (Artifacts.ts: "diff computes … create / update reads the same artifact").
24
+ * Deploy.ts and the Alchemist session both hold one store across the pair.
25
+ * ⚠️ IT FAILS CLOSED. No bag, a bag the plan never wrote, or an object the read cannot prove ours
26
+ * reads as a fresh create, and adopt.ts then asks for `--adopt` — the safe direction.
27
+ * ★ AN UNPROVEN RESUME IS STILL MARKED UNFINISHED (`noteUnfinished`), so `--adopt` can take it over
28
+ * the way Alchemy's own "Cannot resume creating … Re-run with --adopt" offers for a create — while
29
+ * a FRESH replace's new generation, which no diff ever saw without attributes, stays unadoptable.
30
+ */
31
+ import { Unowned } from 'alchemy/AdoptPolicy';
32
+ import { Artifacts } from 'alchemy/Artifacts';
33
+ import * as Effect from 'effect/Effect';
34
+ import * as Option from 'effect/Option';
35
+
36
+ const PROVEN = 'homeflare/ownership/resumes';
37
+ const UNFINISHED = 'homeflare/ownership/unfinished';
38
+
39
+ const note = (key: string, instanceId: string): Effect.Effect<void> =>
40
+ Effect.gen(function* () {
41
+ const bag = yield* Effect.serviceOption(Artifacts);
42
+ if (Option.isSome(bag)) yield* bag.value.set(key, instanceId);
43
+ });
44
+
45
+ const noted = (key: string, instanceId: string): Effect.Effect<boolean> =>
46
+ Effect.gen(function* () {
47
+ const bag = yield* Effect.serviceOption(Artifacts);
48
+ if (Option.isNone(bag)) return false;
49
+ return (yield* bag.value.get<string>(key)) === instanceId;
50
+ });
51
+
52
+ /**
53
+ * `diff`'s note for a row with no attributes: `instanceId` is an unfinished generation of this
54
+ * resource's own, which `--adopt` may resume when nothing proves it (adopt.ts adoptable). Returns
55
+ * `undefined`, a diff's "defer to the engine".
56
+ */
57
+ export const noteUnfinished = (instanceId: string): Effect.Effect<undefined> =>
58
+ Effect.as(note(UNFINISHED, instanceId), undefined);
59
+
60
+ /**
61
+ * Note `instanceId` unfinished, and a proven resume when `recovered` — the family's `read` of the
62
+ * row's props, asked with no attributes — answers a plain object: ours. `Unowned`, nothing, or a
63
+ * read that fails or dies proves nothing. ⚠️ Never fails the plan: an unproven resume is refused
64
+ * at apply instead, unless `--adopt`.
65
+ */
66
+ export const noteResume = <R>(
67
+ instanceId: string,
68
+ recovered: Effect.Effect<unknown, unknown, R>,
69
+ ): Effect.Effect<void, never, R> =>
70
+ Effect.gen(function* () {
71
+ yield* noteUnfinished(instanceId);
72
+ const unproven = () => Effect.succeed(undefined);
73
+ const found = yield* recovered.pipe(Effect.catch(unproven), Effect.catchDefect(unproven));
74
+ if (found === undefined || found === null || Unowned.is(found)) return;
75
+ yield* note(PROVEN, instanceId);
76
+ });
77
+
78
+ /** Whether the plan for this apply noted `instanceId` as a proven resumed create or replace. */
79
+ export const resumes = (instanceId: string): Effect.Effect<boolean> => noted(PROVEN, instanceId);
80
+
81
+ /** Whether the plan for this apply saw `instanceId` as an unfinished generation (proven or not). */
82
+ export const unfinished = (instanceId: string): Effect.Effect<boolean> =>
83
+ noted(UNFINISHED, instanceId);
84
+
85
+ /** The fields of a `read` or `diff` call provingResumes uses; a family's own input carries more. */
86
+ type Ask = {
87
+ readonly id: string;
88
+ readonly fqn: string;
89
+ readonly instanceId: string;
90
+ readonly olds: unknown;
91
+ readonly output: unknown;
92
+ };
93
+ type Hook = Effect.Effect<unknown, unknown, unknown>;
94
+
95
+ /** The two hooks provingResumes wraps, as Alchemy's ProviderService declares them. */
96
+ interface Resumable {
97
+ read?(input: Ask): Hook;
98
+ diff?(input: Ask): Hook;
99
+ }
100
+
101
+ /**
102
+ * A provider whose `diff`, for a row with no attributes, first asks the provider's own `read` whether
103
+ * the live object is that unfinished generation's (noteResume), then runs as written. Every Bao.*
104
+ * family is built through it, so their diffs answer such a row with a plain `undefined`.
105
+ */
106
+ export const provingResumes = <S extends Resumable>(service: S): S => {
107
+ const { diff, read } = service;
108
+ if (diff === undefined || read === undefined) return service;
109
+ const proving: Resumable['diff'] = (input) =>
110
+ Effect.gen(function* () {
111
+ if (input.output === undefined) {
112
+ yield* noteResume(input.instanceId, read({ ...input, output: undefined }));
113
+ }
114
+ return yield* diff(input);
115
+ });
116
+ // ⚠️ The one cast: `diff` keeps the family's own signature — it is the family's diff, run after.
117
+ return { ...service, diff: proving } as S;
118
+ };
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The questions ownership/ asks of Alchemy's state store: does a row record this instance, and with
3
+ * its whole declaration (probe.ts); is this apply a create rather than a replace's new generation
4
+ * (adopt.ts); and — when reconcile refuses a create — forget the row Apply committed for it
5
+ * (adopt.ts). Alchemy's own AWS.EC2.SecurityGroup provider reads the store the same way: the Stack
6
+ * service names the stack and stage, and `State` yields the store.
7
+ *
8
+ * ⚠️ WITHOUT A STACK OR STATE IN CONTEXT, or with a store that errors, a row reads as "not
9
+ * recorded" — the object is then `Unowned`, the safe answer — and nothing is forgotten, which
10
+ * leaves the one gap forgetRefusedCreate exists to close. `alchemy deploy` and the Alchemist
11
+ * session provide both.
12
+ */
13
+ import { Stack } from 'alchemy/Stack';
14
+ import { State, type StateService, isActionState } from 'alchemy/State/State';
15
+ import * as Effect from 'effect/Effect';
16
+ import * as Option from 'effect/Option';
17
+ import { wholeDeclaration } from './whole.ts';
18
+
19
+ type Where = { readonly stack: string; readonly stage: string };
20
+
21
+ /** The store, where this stack lives, and what it declares — or undefined outside a stack. */
22
+ const storeOf = Effect.gen(function* () {
23
+ const stack = yield* Effect.serviceOption(Stack);
24
+ const state = yield* Effect.serviceOption(State);
25
+ if (Option.isNone(stack) || Option.isNone(state)) return undefined;
26
+ const store: StateService = yield* state.value;
27
+ const where: Where = { stack: stack.value.name, stage: stack.value.stage };
28
+ return { resources: stack.value.resources, store, where };
29
+ });
30
+
31
+ const rowAt = (store: StateService, where: Where, fqn: string) =>
32
+ store.get({ ...where, fqn }).pipe(Effect.orElseSucceed(() => undefined));
33
+
34
+ type Generation = {
35
+ readonly instanceId?: unknown;
36
+ readonly status?: unknown;
37
+ readonly props?: unknown;
38
+ readonly attr?: unknown;
39
+ readonly old?: unknown;
40
+ };
41
+
42
+ /** The generation of a persisted row that carries `instanceId`: the row itself, or one in `old`. */
43
+ const generationOf = (row: unknown, instanceId: string): Generation | undefined => {
44
+ let at = row;
45
+ while (typeof at === 'object' && at !== null && !isActionState(at as never)) {
46
+ const generation = at as Generation;
47
+ if (generation.instanceId === instanceId) return generation;
48
+ at = generation.old;
49
+ }
50
+ return undefined;
51
+ };
52
+
53
+ /** The generation `instanceId` names — at the FQN, or at a former FQN `renamedFrom` names. */
54
+ const recordedGeneration = (fqn: string, instanceId: string) =>
55
+ Effect.gen(function* () {
56
+ const found = yield* storeOf;
57
+ if (found === undefined) return undefined;
58
+ for (const at of [fqn, ...(found.resources[fqn]?.FormerFqns ?? [])]) {
59
+ const generation = generationOf(yield* rowAt(found.store, found.where, at), instanceId);
60
+ if (generation !== undefined) return generation;
61
+ }
62
+ return undefined;
63
+ });
64
+
65
+ /**
66
+ * What the state store says of `instanceId` for this resource (at its FQN, or at a former FQN its
67
+ * `renamedFrom` names — the planner migrates that row before it recovers it):
68
+ * · `absent` — no row holds it. ★ This is what separates the probe from the recovery read: the
69
+ * probe's instance id is minted for the probe (Plan.ts `generateInstanceId()`), 128 random bits
70
+ * no row can hold.
71
+ * · `partial` — a row holds it, but its props lack part of the declaration: a prop stripped at
72
+ * commit because it was still an Output (whole.ts). Those props cannot prove an object ours.
73
+ * · `whole` — a row holds it with every prop the declaration names.
74
+ */
75
+ export type Recorded = 'absent' | 'partial' | 'whole';
76
+
77
+ export const recordedInstance = (fqn: string, instanceId: string): Effect.Effect<Recorded> =>
78
+ Effect.gen(function* () {
79
+ const generation = yield* recordedGeneration(fqn, instanceId);
80
+ if (generation === undefined) return 'absent';
81
+ return (yield* wholeDeclaration(fqn, generation.props)) ? 'whole' : 'partial';
82
+ });
83
+
84
+ /**
85
+ * Whether this apply's `instanceId` is a CREATE: the row Apply committed for it is `creating`.
86
+ * ⛔ A REPLACE'S NEW GENERATION IS NOT ONE. Apply commits it as `replacing`, and the planner never
87
+ * offers it adoption — the probe runs only for a resource with no state — so `--adopt` must not
88
+ * let it write over whatever sits at its new identity (adopt.ts adoptsAtApply).
89
+ */
90
+ export const isCreate = (fqn: string, instanceId: string): Effect.Effect<boolean> =>
91
+ Effect.map(
92
+ recordedGeneration(fqn, instanceId),
93
+ (generation) => generation?.status === 'creating',
94
+ );
95
+
96
+ /**
97
+ * ⛔ A REFUSED CREATE MUST NOT LEAVE A ROW THAT CLAIMS THE OBJECT IT REFUSED. Apply commits a
98
+ * `creating` row for this instance before it calls reconcile, and a failed reconcile leaves it
99
+ * there. The next plan's recovery read would then find our instance id and an object that can
100
+ * match its props — a prop that was an Output is stripped from the row, and an omitted prop is
101
+ * "not managed" — and adopt exactly what this apply refused. 🔴 MEASURED 2026-09-21: Bao.Mount
102
+ * and Bao.AuthMethod did (openbao/adopt-core.test.ts). So the row goes, and state reads as if
103
+ * the create never started: the next plan probes, and the probe answers `Unowned`.
104
+ * ⚠️ ONLY a `creating` row of THIS instance with no attributes — the one Apply just wrote. A
105
+ * `replacing` row carries the previous generation in `old` and is never touched.
106
+ */
107
+ export const forgetRefusedCreate = (fqn: string, instanceId: string): Effect.Effect<void> =>
108
+ Effect.gen(function* () {
109
+ const found = yield* storeOf;
110
+ if (found === undefined) return;
111
+ const row = yield* rowAt(found.store, found.where, fqn);
112
+ const mine = row as { status?: unknown; instanceId?: unknown; attr?: unknown } | undefined;
113
+ if (mine?.status !== 'creating' || mine.instanceId !== instanceId || mine.attr !== undefined) {
114
+ return;
115
+ }
116
+ yield* found.store.delete({ ...found.where, fqn }).pipe(Effect.ignore);
117
+ });
@@ -0,0 +1,39 @@
1
+ /**
2
+ * whole.ts `carries`: a state row's props against the declaration — every declared value, Outputs
3
+ * included, needs a value in the row; an optional prop left out of both is no hole.
4
+ */
5
+ import { describe, expect, test } from 'bun:test';
6
+ import { carries } from './whole.ts';
7
+
8
+ /** Stands in for an Output: Alchemy's are function-typed proxies (Diff.ts stripUnresolved). */
9
+ const output = Object.assign(() => undefined, { kind: 'Output' });
10
+
11
+ describe('carries', () => {
12
+ test('a row with every declared value is whole; an omission from both is no hole', () => {
13
+ expect(carries({ name: 'a', ttl: output }, { name: 'a', ttl: '15m' })).toBe(true);
14
+ expect(carries({ name: 'a', ttl: undefined }, { name: 'a' })).toBe(true);
15
+ expect(carries(undefined, { name: 'a' })).toBe(true);
16
+ });
17
+
18
+ test('a declared value — an Output or a literal — missing from the row is a hole', () => {
19
+ expect(carries({ name: output, ttl: '15m' }, { ttl: '15m' })).toBe(false);
20
+ expect(carries({ name: 'a', ttl: '15m' }, { name: 'a' })).toBe(false);
21
+ expect(carries({ name: 'a' }, undefined)).toBe(false);
22
+ });
23
+
24
+ test('holes inside arrays and nested objects count, as JSON stores keep them (null)', () => {
25
+ expect(carries({ policies: ['a', output] }, { policies: ['a', null] })).toBe(false);
26
+ expect(carries({ policies: ['a', output] }, { policies: ['a'] })).toBe(false);
27
+ expect(carries({ policies: [{ groups: [output] }] }, { policies: [{ groups: [] }] })).toBe(
28
+ false,
29
+ );
30
+ expect(carries({ policies: [{ groups: ['g'] }] }, { policies: [{ groups: ['g'] }] })).toBe(
31
+ true,
32
+ );
33
+ });
34
+
35
+ test('a shape the row does not share is not the declaration', () => {
36
+ expect(carries({ policies: ['a'] }, { policies: 'a' })).toBe(false);
37
+ expect(carries({ tune: { ttl: '1h' } }, { tune: '1h' })).toBe(false);
38
+ });
39
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Whether a state row's props say everything the declaration says — the test that separates a row
3
+ * whose props can prove an object ours from one that cannot (probe.ts, rows.ts).
4
+ *
5
+ * ★ WHY A ROW CAN BE MISSING PART OF ITS DECLARATION. Apply commits the `creating` (or `replacing`)
6
+ * row BEFORE reconcile runs, and strips every prop still an Output at that moment (alchemy
7
+ * beta.79 Apply.ts `commit` → Diff.ts `stripUnresolved`); a JSON store then drops the key. So a
8
+ * row written while an upstream was still being created carries a HOLE where that prop was.
9
+ * ⛔ A HOLE IS "UNKNOWN", NOT "UNMANAGED". Most families read an absent prop as "not managed", so a
10
+ * recovery read comparing the live object with holed props proves nothing: 🔴 MEASURED 2026-09-21
11
+ * (openbao/adopt-holes.test.ts), a Bao.Mount whose `defaultLeaseTtl` was an Output, killed before
12
+ * its reconcile ran, adopted — and tuned — another owner's mount on the next deploy; with the
13
+ * NAME an Output, every role family read an empty name, found nothing, and the resumed create
14
+ * wrote over another owner's role. Neither deploy had ever asked whose object it was.
15
+ * ★ THE DECLARATION IS WHAT TELLS A HOLE FROM AN OMISSION. It still holds the Output (or the literal)
16
+ * the row lacks, while an optional prop left out is absent from both.
17
+ * ⚠️ CONSERVATIVE BY DESIGN: a prop added to the declaration after the crash also reads as a hole.
18
+ * That costs one `--adopt`, never a takeover.
19
+ */
20
+ import { Stack } from 'alchemy/Stack';
21
+ import * as Effect from 'effect/Effect';
22
+ import * as Option from 'effect/Option';
23
+
24
+ const absent = (value: unknown): boolean => value === undefined || value === null;
25
+
26
+ const isPlainObject = (value: unknown): value is Record<string, unknown> => {
27
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
28
+ const proto: unknown = Object.getPrototypeOf(value);
29
+ return proto === Object.prototype || proto === null;
30
+ };
31
+
32
+ /**
33
+ * Whether `recorded` carries a value everywhere `declared` does. Plain objects and arrays are
34
+ * walked; any other declared value — a literal, an Output, an Effect — needs a value in the row.
35
+ */
36
+ export const carries = (declared: unknown, recorded: unknown): boolean => {
37
+ if (absent(declared)) return true;
38
+ if (absent(recorded)) return false;
39
+ if (Array.isArray(declared)) {
40
+ if (!Array.isArray(recorded)) return false;
41
+ return declared.every((each, index) => carries(each, recorded[index]));
42
+ }
43
+ if (isPlainObject(declared)) {
44
+ if (!isPlainObject(recorded)) return false;
45
+ return Object.entries(declared).every(([key, each]) => carries(each, recorded[key]));
46
+ }
47
+ return true;
48
+ };
49
+
50
+ /**
51
+ * Whether `props` — one generation's, from the state store — carry the whole declaration of the
52
+ * resource at `fqn`. ⛔ FALSE WITH NOTHING TO COMPARE: no Stack in context, or a resource no longer
53
+ * declared (an orphan's delete). That object then reads as `Unowned` and Apply leaves it in place
54
+ * with a note, rather than deleting what it cannot prove it made.
55
+ */
56
+ export const wholeDeclaration = (fqn: string, props: unknown): Effect.Effect<boolean> =>
57
+ Effect.map(Effect.serviceOption(Stack), (stack) => {
58
+ const declared = Option.isSome(stack) ? stack.value.resources[fqn] : undefined;
59
+ return declared !== undefined && carries(declared.Props, props);
60
+ });