@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
@@ -43,6 +43,9 @@ import { isResolved } from 'alchemy/Diff';
43
43
  import * as Provider from 'alchemy/Provider';
44
44
  import * as Effect from 'effect/Effect';
45
45
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
46
+ import { refuseTakeover } from '../ownership/adopt.ts';
47
+ import { ownedRead } from '../ownership/probe.ts';
48
+ import { provingResumes } from '../ownership/resume.ts';
46
49
  import { baoDelete, baoRead, baoWrite } from './bao-http.ts';
47
50
  import {
48
51
  type BaoPkiRoleAttributes,
@@ -95,8 +98,13 @@ export const BaoPkiRoleProvider = () =>
95
98
  */
96
99
  list: () => Effect.succeed([]),
97
100
 
98
- read: Effect.fn(function* ({ olds }) {
99
- return yield* readRole(olds);
101
+ /** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
102
+ read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
103
+ const found = yield* readRole(olds);
104
+ const ours = Effect.sync(
105
+ () => found !== undefined && problems(olds).length === 0 && matches(found, olds),
106
+ );
107
+ return yield* ownedRead({ fqn, instanceId, output }, found, ours);
100
108
  }),
101
109
 
102
110
  /**
@@ -119,6 +127,7 @@ export const BaoPkiRoleProvider = () =>
119
127
  * and left the old one issuing certificates under no state record at all.
120
128
  */
121
129
  const move = yield* judgeRename(IDENTITY, olds, news, output);
130
+ // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
122
131
  if (output === undefined) return undefined;
123
132
  if (move !== undefined) return { action: 'replace' } as const;
124
133
  if (!isResolved(news)) return undefined;
@@ -137,7 +146,7 @@ export const BaoPkiRoleProvider = () =>
137
146
  : ({ action: 'update' } as const);
138
147
  }),
139
148
 
140
- reconcile: Effect.fn(function* ({ news, output }) {
149
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
141
150
  const path = rolePath(news);
142
151
  // ⛔ An `update` across a move the diff could not see — refused before any write.
143
152
  yield* guardRename(IDENTITY, news, output);
@@ -153,6 +162,8 @@ export const BaoPkiRoleProvider = () =>
153
162
  return yield* Effect.die(new Error(`Bao.PkiRole ${path}: ${bad.join(' ')}`));
154
163
  }
155
164
  const live = yield* readRole(news);
165
+ if (live !== undefined)
166
+ yield* refuseTakeover({ fqn, instanceId, output }, `Bao.PkiRole ${path}`);
156
167
  if (live === undefined || !matches(live, news)) {
157
168
  yield* baoWrite('PUT', path, writeBody(news));
158
169
  }
@@ -195,5 +206,5 @@ export const BaoPkiRoleProvider = () =>
195
206
  return undefined;
196
207
  }),
197
208
  }),
198
- ),
209
+ ).pipe(Effect.map(provingResumes)),
199
210
  );
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import * as Effect from 'effect/Effect';
6
6
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
7
+ import type { Claim } from '../ownership/adopt.ts';
7
8
  import type { BaoError } from './bao-status.ts';
8
9
  import {
9
10
  type BaoPluginAttributes,
@@ -39,18 +40,25 @@ const refuse = (form: BaoPluginForm, message: string) =>
39
40
  * nothing, or finds an OLDER genuinely-unversioned entry. The Cloudflare engine reports one
40
41
  * (`framework.Backend.RunningVersion`). REASONED FROM SOURCE, not measured against a server.
41
42
  * ⚠️ THE WRITE HAS ALREADY LANDED when this fires, so the catalog holds a registration no state
42
- * record names. Declaring the reported version adopts it on the next deploy (same sha256 and
43
- * command, so reconcile matches and writes nothing); declaring a DIFFERENT version is refused by
44
- * the server itself (setInternal :1219-1221, "plugin version mismatch").
43
+ * record names. Declaring the reported version and deploying once with `--adopt` adopts it (same
44
+ * sha256 and command, so reconcile matches and writes nothing); declaring a DIFFERENT version is
45
+ * refused by the server itself (setInternal :1219-1221, "plugin version mismatch").
46
+ * ⚠️ `--adopt`, NOT A SILENT RESUME (2026-09-21): the row this create left names the UNVERSIONED
47
+ * entry, so it cannot prove the versioned one ours (docs/ownership.md).
45
48
  */
46
49
  const SELF_REPORTED =
47
50
  'the write succeeded but the read-back does not match. If the binary reports its own version, ' +
48
51
  'OpenBao filed the registration under it (plugin_catalog.go setInternal) — declare `version` ' +
49
- 'as exactly the version the binary reports; that adopts the entry this write just made.';
52
+ 'as exactly the version the binary reports, and deploy once with --adopt: that adopts the ' +
53
+ 'entry this write just made.';
50
54
 
51
- /** Register when the live entry differs, then prove it by reading back. Dies on a refusal. */
55
+ /**
56
+ * Register when the live entry differs, then prove it by reading back. Dies on a refusal.
57
+ * ⛔ `claim` (the provider passes it): a create never takes over an entry already registered.
58
+ */
52
59
  export const reconcilePlugin = (
53
60
  props: BaoPluginProps,
61
+ claim?: Claim,
54
62
  ): Effect.Effect<BaoPluginAttributes, BaoError, HttpClient.HttpClient> =>
55
63
  Effect.gen(function* () {
56
64
  const form = resolve(props);
@@ -58,6 +66,9 @@ export const reconcilePlugin = (
58
66
  if (bad.length > 0) return yield* refuse(form, bad.join('; '));
59
67
 
60
68
  const found = yield* readEntry(form);
69
+ if (found !== undefined && claim !== undefined) {
70
+ yield* claim(`Bao.Plugin ${versionedPath(form.type, form.name, form.version)}`);
71
+ }
61
72
  if (found !== undefined && !matches(found.attributes, form)) {
62
73
  /**
63
74
  * ⛔ A DECLARATIVE OR OCI ENTRY BELONGS TO THE SERVER'S CONFIG FILE (`plugin` stanzas with
@@ -24,11 +24,15 @@ import { isResolved } from 'alchemy/Diff';
24
24
  import * as Provider from 'alchemy/Provider';
25
25
  import * as Effect from 'effect/Effect';
26
26
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
27
+ import { claimFor } from '../ownership/adopt.ts';
28
+ import { ownedRead } from '../ownership/probe.ts';
29
+ import { provingResumes } from '../ownership/resume.ts';
27
30
  import {
28
31
  type BaoPluginAttributes,
29
32
  type BaoPluginProps,
30
33
  type BaoPluginType,
31
34
  matches,
35
+ problems,
32
36
  resolve,
33
37
  versionedPath,
34
38
  } from './plugin-form.ts';
@@ -81,9 +85,15 @@ export const BaoPluginProvider = () =>
81
85
  */
82
86
  list: () => Effect.succeed([]),
83
87
 
84
- read: Effect.fn(function* ({ olds }) {
85
- const found = yield* readEntry(resolve(olds));
86
- return found?.attributes;
88
+ /** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
89
+ read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
90
+ const form = resolve(olds);
91
+ const found = yield* readEntry(form);
92
+ const ours = Effect.sync(
93
+ () =>
94
+ found !== undefined && problems(form).length === 0 && matches(found.attributes, form),
95
+ );
96
+ return yield* ownedRead({ fqn, instanceId, output }, found?.attributes, ours);
87
97
  }),
88
98
 
89
99
  /** ⛔ IT COMPARES THE LIVE ENTRY, NOT THE STORED DIGEST — a hand re-register is drift. */
@@ -95,6 +105,7 @@ export const BaoPluginProvider = () =>
95
105
  * that is already registered fails the plan (rename-identity.ts).
96
106
  */
97
107
  const move = yield* judgeRename(IDENTITY, olds, news, output);
108
+ // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
98
109
  if (output === undefined) return undefined;
99
110
  if (move !== undefined) return { action: 'replace' } as const;
100
111
  if (!isResolved(news)) return undefined;
@@ -107,10 +118,10 @@ export const BaoPluginProvider = () =>
107
118
  }),
108
119
 
109
120
  /** The refusals — declarative, builtin, self-reported version — live in plugin-reconcile.ts. */
110
- reconcile: Effect.fn(function* ({ news, output }) {
121
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
111
122
  // ⛔ An `update` across a move the diff could not see — refused before any write.
112
123
  yield* guardRename(IDENTITY, news, output);
113
- return yield* reconcilePlugin(news);
124
+ return yield* reconcilePlugin(news, claimFor({ fqn, instanceId, output }));
114
125
  }),
115
126
 
116
127
  /**
@@ -122,5 +133,5 @@ export const BaoPluginProvider = () =>
122
133
  return undefined;
123
134
  }),
124
135
  }),
125
- ),
136
+ ).pipe(Effect.map(provingResumes)),
126
137
  );
@@ -5,6 +5,9 @@ import * as Effect from 'effect/Effect';
5
5
  import * as FileSystem from 'effect/FileSystem';
6
6
  import * as Path from 'effect/Path';
7
7
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
8
+ import { refuseTakeover } from '../ownership/adopt.ts';
9
+ import { ownedRead } from '../ownership/probe.ts';
10
+ import { provingResumes } from '../ownership/resume.ts';
8
11
  import { sha256 } from './digest.ts';
9
12
  import { isEmptyAssembly } from './policy-assembly.ts';
10
13
  import { deletePolicy, policyPath, readPolicy, writePolicy } from './policy-wire.ts';
@@ -138,9 +141,16 @@ export const BaoPolicyProvider = () =>
138
141
  list: () => Effect.succeed([]),
139
142
 
140
143
  // ★ '' means genuinely absent (a 404); a refusal fails. policy-wire.ts has the history.
141
- read: Effect.fn(function* ({ olds }) {
144
+ // ⛔ With no attributes in state, a live policy is `Unowned` unless it is the one our own
145
+ // interrupted create wrote: these fragments, byte for byte (ownership/probe.ts).
146
+ read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
142
147
  const live = yield* readPolicy(olds.name);
143
- return live.length === 0 ? undefined : attributesOf(olds.name, live, 0);
148
+ const found = live.length === 0 ? undefined : attributesOf(olds.name, live, 0);
149
+ const ours = Effect.map(
150
+ assemble(olds.fragments),
151
+ (a) => sha256(a.joined) === found?.digest,
152
+ );
153
+ return yield* ownedRead({ fqn, instanceId, output }, found, ours);
144
154
  }),
145
155
 
146
156
  /**
@@ -161,6 +171,7 @@ export const BaoPolicyProvider = () =>
161
171
  * PR (REPLACE.md).
162
172
  */
163
173
  const move = yield* judgeRename(IDENTITY, olds, news, output);
174
+ // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
164
175
  if (output === undefined) return undefined;
165
176
  if (move !== undefined) return { action: 'replace' } as const;
166
177
  // ⚠️ A prop can still be an unresolved Output or Config at plan time. Docker's
@@ -174,7 +185,7 @@ export const BaoPolicyProvider = () =>
174
185
  : ({ action: 'update' } as const);
175
186
  }),
176
187
 
177
- reconcile: Effect.fn(function* ({ news, output }) {
188
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
178
189
  const name = news.name;
179
190
  // ⛔ An `update` across a rename the diff could not see — refused before any write.
180
191
  yield* guardRename(IDENTITY, news, output);
@@ -207,6 +218,8 @@ export const BaoPolicyProvider = () =>
207
218
  * sibling PVE provider does exactly this in pveOperations.reconcile.
208
219
  */
209
220
  const current = yield* readPolicy(name);
221
+ if (current.length > 0)
222
+ yield* refuseTakeover({ fqn, instanceId, output }, `Bao.Policy ${name}`);
210
223
  if (current.length > 0 && sha256(current) === sha256(joined)) {
211
224
  return attributesOf(name, joined, parts);
212
225
  }
@@ -231,5 +244,5 @@ export const BaoPolicyProvider = () =>
231
244
  return undefined;
232
245
  }),
233
246
  });
234
- }),
247
+ }).pipe(Effect.map(provingResumes)),
235
248
  );
@@ -40,6 +40,9 @@ import { isResolved } from 'alchemy/Diff';
40
40
  import * as Provider from 'alchemy/Provider';
41
41
  import * as Effect from 'effect/Effect';
42
42
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
43
+ import { refuseTakeover } from '../ownership/adopt.ts';
44
+ import { ownedRead } from '../ownership/probe.ts';
45
+ import { provingResumes } from '../ownership/resume.ts';
43
46
  import { baoDelete, baoWrite } from './bao-http.ts';
44
47
  import {
45
48
  type BaoProxmoxRoleAttributes,
@@ -84,8 +87,11 @@ export const BaoProxmoxRoleProvider = () =>
84
87
  */
85
88
  list: () => Effect.succeed([]),
86
89
 
87
- read: Effect.fn(function* ({ olds }) {
88
- return yield* readRole(olds);
90
+ /** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
91
+ read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
92
+ const found = yield* readRole(olds);
93
+ const ours = Effect.sync(() => found !== undefined && matches(found, olds));
94
+ return yield* ownedRead({ fqn, instanceId, output }, found, ours);
89
95
  }),
90
96
 
91
97
  /**
@@ -107,6 +113,7 @@ export const BaoProxmoxRoleProvider = () =>
107
113
  * resulting `update`.
108
114
  */
109
115
  const move = yield* judgeRename(IDENTITY, olds, news, output);
116
+ // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
110
117
  if (output === undefined) return undefined;
111
118
  if (move !== undefined) {
112
119
  const mintUser = declaredString(news, 'mintUser');
@@ -142,11 +149,13 @@ export const BaoProxmoxRoleProvider = () =>
142
149
  return { action: 'update' } as const;
143
150
  }),
144
151
 
145
- reconcile: Effect.fn(function* ({ news, output }) {
152
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
146
153
  const path = rolePath(news.mount, news.name);
147
154
  // ⛔ An `update` across a move the diff could not see — refused before any read or write.
148
155
  yield* guardRename(IDENTITY, news, output);
149
156
  const live = yield* readRole(news);
157
+ if (live !== undefined)
158
+ yield* refuseTakeover({ fqn, instanceId, output }, `Bao.ProxmoxRole ${path}`);
150
159
  /**
151
160
  * ⛔ A mint_user CHANGE RE-SCOPES EVERY CREDENTIAL THE ROLE WILL EVER MINT, SILENTLY.
152
161
  * The minted token inherits that PVE user's standing ACL and nothing else, so
@@ -226,5 +235,5 @@ export const BaoProxmoxRoleProvider = () =>
226
235
  return undefined;
227
236
  }),
228
237
  }),
229
- ),
238
+ ).pipe(Effect.map(provingResumes)),
230
239
  );
@@ -1,15 +1,15 @@
1
1
  /**
2
- * The swap the plan-time guard cannot see: a NEW declaration of a name that is live. 🔴 MEASURED
3
- * 2026-09-21 through Alchemy's own plan and apply (fake-stack.ts). With no state row, the engine asks
4
- * `read`, which finds the object and answers plain attributes, so Alchemy silently adopts it
5
- * (alchemy@2.0.0-beta.79 Plan.ts, the adoption probe; AdoptPolicy.ts). Nothing moved, so
6
- * `judgeMove` never runs. Under `RemovalPolicy.destroy()` the old owner's delete then runs in
7
- * Apply's phase 2, after the adoption has written, and removes the object the new declaration now
8
- * claims. The deploy is green.
9
- *
10
- * ★ These pin that, and the two ways round it REPLACE.md gives: `renamedFrom` for a logical id that
11
- * changes, and a second deploy for a name another resource is leaving. If one of the hazards stops
12
- * reproducing, the engine changed: re-read REPLACE.md before relaxing it.
2
+ * The swap the plan-time rename guard cannot see: a NEW declaration of a name that is live.
3
+ * 🔴 MEASURED 2026-09-21 through Alchemy's own plan and apply (fake-stack.ts), before the ownership
4
+ * rule: with no state row the engine asked `read`, which answered plain attributes, so Alchemy
5
+ * silently adopted the object (alchemy@2.0.0-beta.79 Plan.ts, the adoption probe; AdoptPolicy.ts).
6
+ * Under `RemovalPolicy.destroy()` the old owner's delete then ran in Apply's phase 2 and removed the
7
+ * object the new declaration claimed — in a green deploy.
8
+ * ★ NOW (decided 2026-09-21): the probe answers `Unowned` for any live object (ownership/probe.ts),
9
+ * so that plan FAILS before anything is written, and the takeover happens only when asked for —
10
+ * which is exactly the old hazard, on purpose. These pin both, and the two ways round it
11
+ * REPLACE.md gives: `renamedFrom` for a logical id that changes, and a second deploy for a name
12
+ * another resource is leaving.
13
13
  *
14
14
  * ⛔ TEST-ONLY. Every value is a placeholder, not the estate's.
15
15
  */
@@ -25,14 +25,25 @@ const { destroy } = RemovalPolicy;
25
25
 
26
26
  /** No delete endpoint (jwt-config.ts), and a delete the provider refuses (mfa-enforcement.ts). */
27
27
  const KEEPS = new Set(['Bao.JwtAuthConfig', 'Bao.MfaLoginEnforcement']);
28
- const REFUSES_DELETE = /Bao\.MfaLoginEnforcement/;
28
+ const REFUSES_DELETE = /Bao\.MfaLoginEnforcement a: REFUSING to delete/;
29
+ const NOT_OWNED = /Cannot adopt resource 'Z'.*--adopt/s;
29
30
 
30
31
  describe('a logical id changed without renamedFrom, under destroy', () => {
31
32
  for (const row of FAMILIES) {
32
- it(`${row.family}: the orphan delete removes what the new id just adopted`, async () => {
33
+ it(`${row.family}: the plan refuses the live name, and nothing is written or deleted`, async () => {
34
+ await withEstate(async (stack, estate, seen) => {
35
+ await stack.deploy(row.declare('X', 'a', '15m', destroy));
36
+ seen.length = 0;
37
+ await assert.rejects(stack.deploy(row.declare('Z', 'a', '15m', destroy)), NOT_OWNED);
38
+ assert.deepEqual(writesOf(seen), []);
39
+ assert.ok(row.has(estate, 'a'));
40
+ });
41
+ });
42
+
43
+ it(`${row.family}: --adopt is the takeover, and the old id's delete follows it`, async () => {
33
44
  await withEstate(async (stack, estate) => {
34
45
  await stack.deploy(row.declare('X', 'a', '15m', destroy));
35
- const again = stack.deploy(row.declare('Z', 'a', '15m', destroy));
46
+ const again = stack.deploy(row.declare('Z', 'a', '15m', destroy), { adopt: true });
36
47
  if (row.family === 'Bao.MfaLoginEnforcement') await assert.rejects(again, REFUSES_DELETE);
37
48
  else assert.deepEqual(await again, { X: 'delete', Z: 'adopted' });
38
49
  assert.equal(row.has(estate, 'a'), KEEPS.has(row.family));
@@ -55,19 +66,21 @@ describe('the ways round it', () => {
55
66
  });
56
67
  });
57
68
 
58
- it('a name another resource moves off is lost when taken in the same deploy', async () => {
59
- await withEstate(async (stack, estate) => {
69
+ it('a name another resource moves off is refused when taken in the same deploy', async () => {
70
+ await withEstate(async (stack, estate, seen) => {
60
71
  await stack.deploy(row.declare('X', 'a', '15m', destroy));
72
+ seen.length = 0;
61
73
  const both = Effect.gen(function* () {
62
74
  yield* row.declare('X', 'b', '15m', destroy);
63
75
  yield* row.declare('Z', 'a', '30m', destroy);
64
76
  });
65
- assert.deepEqual(await stack.deploy(both), { X: 'replace', Z: 'adopted' });
66
- assert.ok(row.has(estate, 'b') && !row.has(estate, 'a'));
77
+ await assert.rejects(stack.deploy(both), NOT_OWNED);
78
+ assert.deepEqual(writesOf(seen), []);
79
+ assert.ok(row.has(estate, 'a') && !row.has(estate, 'b'));
67
80
  });
68
81
  });
69
82
 
70
- it('and survives when taken in the deploy after the move', async () => {
83
+ it('and succeeds when taken in the deploy after the move', async () => {
71
84
  await withEstate(async (stack, estate) => {
72
85
  await stack.deploy(row.declare('X', 'a', '15m', destroy));
73
86
  await stack.deploy(row.declare('X', 'b', '15m', destroy));
@@ -15,6 +15,8 @@
15
15
  */
16
16
  import * as Effect from 'effect/Effect';
17
17
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
18
+ import type { Claim, Owner } from '../ownership/adopt.ts';
19
+ import { ownedRead } from '../ownership/probe.ts';
18
20
  import { baoRead, baoWrite } from './bao-http.ts';
19
21
  import type { BaoError } from './bao-status.ts';
20
22
 
@@ -40,6 +42,21 @@ export const readRoleAt = <A>(
40
42
  live === undefined ? undefined : { attributes: spec.attributesOf(live), live },
41
43
  );
42
44
 
45
+ /**
46
+ * `read`: the live attributes — `Unowned` with no state unless our own interrupted create wrote
47
+ * them, which is `planRole`'s `noop` against that create's props (ownership/probe.ts).
48
+ */
49
+ export const readOwnedRole = <A extends object>(
50
+ ask: Owner,
51
+ spec: RoleSpec<A>,
52
+ ): Effect.Effect<A | undefined, BaoError, HttpClient.HttpClient> =>
53
+ Effect.flatMap(readRoleAt(spec), (found) => {
54
+ const ours = Effect.sync(
55
+ () => found !== undefined && spec.problems.length === 0 && spec.matches(found.attributes),
56
+ );
57
+ return ownedRead(ask, found?.attributes, ours);
58
+ });
59
+
43
60
  /** `update` or `noop` — the part of a diff after the identity checks. */
44
61
  export const planRole = <A>(
45
62
  spec: RoleSpec<A>,
@@ -53,12 +70,18 @@ export const planRole = <A>(
53
70
  const refuse = (spec: { readonly family: string; readonly path: string }, message: string) =>
54
71
  Effect.die(new Error(`${spec.family} ${spec.path}: ${message}`));
55
72
 
73
+ /**
74
+ * ⛔ `claim` — every provider passes it — refuses, before any write, a create that finds a live
75
+ * object this stack holds no state for (ownership/adopt.ts).
76
+ */
56
77
  export const reconcileRole = <A>(
57
78
  spec: RoleSpec<A>,
79
+ claim?: Claim,
58
80
  ): Effect.Effect<A, BaoError, HttpClient.HttpClient> =>
59
81
  Effect.gen(function* () {
60
82
  if (spec.problems.length > 0) return yield* refuse(spec, spec.problems.join('; '));
61
83
  const found = yield* readRoleAt(spec);
84
+ if (found !== undefined && claim !== undefined) yield* claim(`${spec.family} ${spec.path}`);
62
85
  if (found === undefined || !spec.matches(found.attributes)) {
63
86
  const lost = found === undefined ? [] : (spec.wouldErase?.(found.live) ?? []);
64
87
  if (lost.length > 0) {
@@ -36,6 +36,9 @@ import { isResolved } from 'alchemy/Diff';
36
36
  import * as Provider from 'alchemy/Provider';
37
37
  import * as Effect from 'effect/Effect';
38
38
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
39
+ import { refuseTakeover } from '../ownership/adopt.ts';
40
+ import { ownedRead } from '../ownership/probe.ts';
41
+ import { provingResumes } from '../ownership/resume.ts';
39
42
  import { baoDelete, baoRead, baoWrite } from './bao-http.ts';
40
43
  import {
41
44
  type BaoSshRoleAttributes,
@@ -91,9 +94,13 @@ export const BaoSshRoleProvider = () =>
91
94
  */
92
95
  list: () => Effect.succeed([]),
93
96
 
94
- read: Effect.fn(function* ({ olds }) {
97
+ /** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
98
+ read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
95
99
  const found = yield* readRole(resolve(olds));
96
- return found?.attributes;
100
+ const ours = Effect.sync(
101
+ () => found !== undefined && matches(found.attributes, resolve(olds)),
102
+ );
103
+ return yield* ownedRead({ fqn, instanceId, output }, found?.attributes, ours);
97
104
  }),
98
105
 
99
106
  /**
@@ -115,6 +122,7 @@ export const BaoSshRoleProvider = () =>
115
122
  * ⛔ a move onto a role that exists fails the plan (rename-identity.ts).
116
123
  */
117
124
  const move = yield* judgeRename(IDENTITY, olds, news, output);
125
+ // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
118
126
  if (output === undefined) return undefined;
119
127
  if (move !== undefined) return { action: 'replace' } as const;
120
128
  if (!isResolved(news)) return undefined;
@@ -126,7 +134,7 @@ export const BaoSshRoleProvider = () =>
126
134
  : ({ action: 'update' } as const);
127
135
  }),
128
136
 
129
- reconcile: Effect.fn(function* ({ news, output }) {
137
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
130
138
  const form = resolve(news);
131
139
  const path = readPath(form.mount, form.name);
132
140
  // ⛔ An `update` across a move the diff could not see — refused before any write.
@@ -157,6 +165,8 @@ export const BaoSshRoleProvider = () =>
157
165
  }
158
166
 
159
167
  const found = yield* readRole(form);
168
+ if (found !== undefined)
169
+ yield* refuseTakeover({ fqn, instanceId, output }, `Bao.SshRole ${path}`);
160
170
  if (found === undefined || !matches(found.attributes, form)) {
161
171
  if (found !== undefined) {
162
172
  const lost = wouldErase(found.live);
@@ -197,5 +207,5 @@ export const BaoSshRoleProvider = () =>
197
207
  return undefined;
198
208
  }),
199
209
  }),
200
- ),
210
+ ).pipe(Effect.map(provingResumes)),
201
211
  );
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Whether a live object this stack holds no state for may be written — the apply-time half of the
3
+ * house rule that NOTHING IS ADOPTED WITHOUT `--adopt` (decided 2026-09-21 for HostFile, LaunchdJob,
4
+ * CaddyConfig and every Bao.* family). probe.ts is the plan-time half.
5
+ *
6
+ * ★ FAMILY-NEUTRAL ON PURPOSE. The engine questions here — what `--adopt` resolves to for one FQN,
7
+ * whether this apply resumes the resource's own interrupted create — are the same for a vault
8
+ * role, a launchd plist and a Caddy config, so they live once, outside every subpath's directory.
9
+ */
10
+ import { AdoptPolicy } from 'alchemy/AdoptPolicy';
11
+ import { AlchemyContext } from 'alchemy/AlchemyContext';
12
+ import { Stack } from 'alchemy/Stack';
13
+ import * as Effect from 'effect/Effect';
14
+ import * as Option from 'effect/Option';
15
+ import { resumes, unfinished } from './resume.ts';
16
+ import { forgetRefusedCreate, isCreate } from './rows.ts';
17
+
18
+ /**
19
+ * Whether the resource at `fqn` may be adopted, resolved exactly as the planner resolves it (alchemy
20
+ * beta.79 Plan.ts: `resource.Adopt ?? shouldAdopt`): a resource-scoped `adopt(…)`, else the
21
+ * `AdoptPolicy` service, else `AlchemyContext.adopt`, else off.
22
+ * ★ FOR A CREATE THE ENGINE NEVER PROBED. Alchemy skips the adoption probe while `news` holds an
23
+ * Output, so an ownership check that must still honour `--adopt` can only run at apply. All three
24
+ * reach the apply: Alchemist runs plan AND apply under the session context, which provides the
25
+ * services from the CLI flag and the Stack the resources registered on (Alchemist/Session.ts,
26
+ * routes/stack.ts; Apply.ts reads the same Stack).
27
+ * ⛔ THE RESOURCE-SCOPED SETTING WINS, BOTH WAYS. The engine captures `adopt(…)` on the resource at
28
+ * registration (Resource.ts `Adopt`, into `Stack.resources[fqn]`) and never hands it to a
29
+ * provider. Reading only the deploy-wide flag let `--adopt` take over a resource declared
30
+ * `.pipe(adopt(false))` — which the planner refuses — and refused one declared `adopt(true)`.
31
+ * ⚠️ `resources` carries an `@internal` comment in Alchemy's source, but it is in the published types
32
+ * and the planner reads `Adopt` from it. caddy/adopt-scope.test.ts registers through the real
33
+ * `Resource`, so an upgrade that stops recording it there fails the suite.
34
+ */
35
+ export const adoptEnabled = (fqn: string): Effect.Effect<boolean> =>
36
+ Effect.gen(function* () {
37
+ const stack = yield* Effect.serviceOption(Stack);
38
+ const scoped = Option.isSome(stack) ? stack.value.resources[fqn]?.Adopt : undefined;
39
+ if (scoped !== undefined) return scoped;
40
+ const policy = yield* Effect.serviceOption(AdoptPolicy);
41
+ if (Option.isSome(policy)) return policy.value;
42
+ const context = yield* Effect.serviceOption(AlchemyContext);
43
+ return Option.isSome(context) ? context.value.adopt : false;
44
+ });
45
+
46
+ /** The three fields of a `reconcile` (or `read`) call that say whose object this is. */
47
+ export type Owner = {
48
+ readonly fqn: string;
49
+ readonly instanceId: string;
50
+ /** The attributes state holds; `undefined` on a create. */
51
+ readonly output: unknown;
52
+ };
53
+
54
+ /**
55
+ * Whether `--adopt` may speak for this apply's generation: a CREATE (rows.ts isCreate), or an
56
+ * UNFINISHED generation whose row this plan's diff saw with no attributes (resume.ts) — the
57
+ * resume Alchemy itself offers `--adopt` for ("Cannot resume creating … Re-run with --adopt").
58
+ * ⛔ NOT A FRESH REPLACE'S NEW GENERATION. The planner never probes it and never offers it adoption,
59
+ * so the identity it moves to was never asked about under `--adopt`: 🔴 MEASURED 2026-09-21
60
+ * (launchd/adopt-apply.test.ts), a HostFile whose path changed onto a file it did not own
61
+ * overwrote that file under a deploy-wide `--adopt` meant for something else. A Bao rename onto a
62
+ * taken identity already fails the plan, `--adopt` or not (rename.ts judgeMove); this keeps the
63
+ * apply to the same answer.
64
+ */
65
+ const adoptable = (owner: Owner): Effect.Effect<boolean> =>
66
+ Effect.gen(function* () {
67
+ if (yield* isCreate(owner.fqn, owner.instanceId)) return true;
68
+ return yield* unfinished(owner.instanceId);
69
+ });
70
+
71
+ /** Whether a stateless `reconcile` may take over a live object: adoption on, for an adoptable one. */
72
+ export const adoptsAtApply = (owner: Owner): Effect.Effect<boolean> =>
73
+ Effect.gen(function* () {
74
+ if (!(yield* adoptEnabled(owner.fqn))) return false;
75
+ return yield* adoptable(owner);
76
+ });
77
+
78
+ /** A lifecycle helper's hook for refuseTakeover, bound to one call's Owner: `(what) => …`. */
79
+ export type Claim = (what: string) => Effect.Effect<void>;
80
+
81
+ /**
82
+ * `reconcile`'s check, called with the live object it found BEFORE any write: dies unless that
83
+ * object may be written. It may when state already vouches for it (`output`), when this apply
84
+ * resumes the resource's own interrupted create or replace and its diff proved the object that
85
+ * generation's (resume.ts), or when adoption is on for a create or an unfinished generation
86
+ * (adoptable).
87
+ * ⛔ WHY IT EXISTS AT ALL: THE PROBE DOES NOT GUARD EVERY CREATE. Alchemy skips it while `news` holds
88
+ * an Output (Plan.ts), a new generation of a replace is never probed, and an object can appear
89
+ * between plan and apply. Each of those used to be a silent takeover: written over, then recorded
90
+ * as ours, and deleted with the stack under `destroy`.
91
+ * ⚠️ A CHECK, THEN A WRITE — NOT A LOCK. Two creates of one name in the same deploy run
92
+ * concurrently, both read nothing, and both write (measured with the fakes, 2026-09-21). Nothing
93
+ * between a read and a write can close that; declare each name once.
94
+ * ⚠️ AN IDENTICAL OBJECT IS STILL A TAKEOVER (decided 2026-09-21). Nothing would be written, but the
95
+ * state row would claim it, and a later delete would remove what another owner still relies on.
96
+ * ⛔ A REFUSED CREATE FORGETS ITS `creating` ROW (rows.ts forgetRefusedCreate), or the next plan's
97
+ * recovery read would adopt the very object this refused.
98
+ */
99
+ export const refuseTakeover = (owner: Owner, what: string): Effect.Effect<void> =>
100
+ Effect.gen(function* () {
101
+ if (owner.output !== undefined) return;
102
+ if (yield* resumes(owner.instanceId)) return;
103
+ const may = yield* adoptable(owner);
104
+ if (may && (yield* adoptEnabled(owner.fqn))) return;
105
+ yield* forgetRefusedCreate(owner.fqn, owner.instanceId);
106
+ const remedy = may
107
+ ? 'Deploy with --adopt (or wrap the resource in adopt(true)) to take it over, or remove it first.'
108
+ : 'It is the new identity of a replace, which --adopt does not cover: remove it first.';
109
+ return yield* Effect.die(
110
+ new Error(
111
+ `${what}: already exists, and this stack holds no state for it — the plan could not ask ` +
112
+ 'first (a prop was still an Output, a replace moved onto it, or it appeared after the ' +
113
+ `plan). Nothing was written. ${remedy}`,
114
+ ),
115
+ );
116
+ });
117
+
118
+ /** refuseTakeover bound to one call's Owner, for a lifecycle helper that finds the live object. */
119
+ export const claimFor =
120
+ (owner: Owner): Claim =>
121
+ (what) =>
122
+ refuseTakeover(owner, what);