@homeflare/alchemy 0.7.0 → 0.10.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 (312) hide show
  1. package/README.md +45 -45
  2. package/dist/caddy/config-lifecycle.d.ts +49 -3
  3. package/dist/caddy/config-lifecycle.d.ts.map +1 -1
  4. package/dist/caddy/config.d.ts +6 -2
  5. package/dist/caddy/config.d.ts.map +1 -1
  6. package/dist/caddy/fake-caddy.d.ts +11 -0
  7. package/dist/caddy/fake-caddy.d.ts.map +1 -1
  8. package/dist/caddy/index.d.ts +2 -1
  9. package/dist/caddy/index.d.ts.map +1 -1
  10. package/dist/caddy/index.js +270 -44
  11. package/dist/caddy/index.js.map +15 -11
  12. package/dist/caddy/with-file.d.ts +7 -3
  13. package/dist/caddy/with-file.d.ts.map +1 -1
  14. package/dist/launchd/fake-runner.d.ts +1 -0
  15. package/dist/launchd/fake-runner.d.ts.map +1 -1
  16. package/dist/launchd/fake-sudo.d.ts +51 -0
  17. package/dist/launchd/fake-sudo.d.ts.map +1 -0
  18. package/dist/launchd/host-effect.d.ts +1 -1
  19. package/dist/launchd/host-file-form.d.ts +5 -0
  20. package/dist/launchd/host-file-form.d.ts.map +1 -1
  21. package/dist/launchd/host-file-lifecycle.d.ts +14 -1
  22. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  23. package/dist/launchd/host-file.d.ts +2 -1
  24. package/dist/launchd/host-file.d.ts.map +1 -1
  25. package/dist/launchd/index.d.ts +2 -0
  26. package/dist/launchd/index.d.ts.map +1 -1
  27. package/dist/launchd/index.js +642 -49
  28. package/dist/launchd/index.js.map +24 -14
  29. package/dist/launchd/job-lifecycle.d.ts +8 -1
  30. package/dist/launchd/job-lifecycle.d.ts.map +1 -1
  31. package/dist/launchd/job-preflight.d.ts +13 -1
  32. package/dist/launchd/job-preflight.d.ts.map +1 -1
  33. package/dist/launchd/job-validate.d.ts +2 -0
  34. package/dist/launchd/job-validate.d.ts.map +1 -1
  35. package/dist/launchd/job.d.ts +2 -1
  36. package/dist/launchd/job.d.ts.map +1 -1
  37. package/dist/launchd/local-runner.d.ts +2 -0
  38. package/dist/launchd/local-runner.d.ts.map +1 -1
  39. package/dist/launchd/providers.d.ts +2 -1
  40. package/dist/launchd/providers.d.ts.map +1 -1
  41. package/dist/launchd/runner.d.ts +14 -5
  42. package/dist/launchd/runner.d.ts.map +1 -1
  43. package/dist/launchd/sudo-acl.d.ts +25 -0
  44. package/dist/launchd/sudo-acl.d.ts.map +1 -0
  45. package/dist/launchd/sudo-allowlist.d.ts +47 -0
  46. package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
  47. package/dist/launchd/sudo-guard.d.ts +83 -0
  48. package/dist/launchd/sudo-guard.d.ts.map +1 -0
  49. package/dist/launchd/sudo-runner.d.ts +60 -0
  50. package/dist/launchd/sudo-runner.d.ts.map +1 -0
  51. package/dist/launchd/sudo-said.d.ts +10 -0
  52. package/dist/launchd/sudo-said.d.ts.map +1 -0
  53. package/dist/launchd/sudo-stage.d.ts +7 -0
  54. package/dist/launchd/sudo-stage.d.ts.map +1 -0
  55. package/dist/openbao/auth-method-reconcile.d.ts +6 -2
  56. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -1
  57. package/dist/openbao/auth-method.d.ts.map +1 -1
  58. package/dist/openbao/auth-role-form.d.ts +0 -9
  59. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  60. package/dist/openbao/auth-role.d.ts +3 -2
  61. package/dist/openbao/auth-role.d.ts.map +1 -1
  62. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  63. package/dist/openbao/fake-bao.d.ts +18 -0
  64. package/dist/openbao/fake-bao.d.ts.map +1 -1
  65. package/dist/openbao/fake-core.d.ts +39 -0
  66. package/dist/openbao/fake-core.d.ts.map +1 -0
  67. package/dist/openbao/fake-engines-roles.d.ts +32 -0
  68. package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
  69. package/dist/openbao/fake-engines.d.ts +8 -2
  70. package/dist/openbao/fake-engines.d.ts.map +1 -1
  71. package/dist/openbao/fake-families.d.ts +52 -0
  72. package/dist/openbao/fake-families.d.ts.map +1 -0
  73. package/dist/openbao/fake-mounts.d.ts +16 -0
  74. package/dist/openbao/fake-mounts.d.ts.map +1 -0
  75. package/dist/openbao/fake-stack.d.ts +5 -1
  76. package/dist/openbao/fake-stack.d.ts.map +1 -1
  77. package/dist/openbao/host-approles.d.ts.map +1 -1
  78. package/dist/openbao/index.js +709 -359
  79. package/dist/openbao/index.js.map +32 -25
  80. package/dist/openbao/jwt-config.d.ts +5 -0
  81. package/dist/openbao/jwt-config.d.ts.map +1 -1
  82. package/dist/openbao/jwt-role.d.ts +2 -1
  83. package/dist/openbao/jwt-role.d.ts.map +1 -1
  84. package/dist/openbao/kubernetes-role.d.ts +2 -1
  85. package/dist/openbao/kubernetes-role.d.ts.map +1 -1
  86. package/dist/openbao/mfa-enforcement.d.ts +12 -5
  87. package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
  88. package/dist/openbao/mfa-totp.d.ts +6 -2
  89. package/dist/openbao/mfa-totp.d.ts.map +1 -1
  90. package/dist/openbao/mount-reconcile.d.ts +5 -1
  91. package/dist/openbao/mount-reconcile.d.ts.map +1 -1
  92. package/dist/openbao/mount.d.ts.map +1 -1
  93. package/dist/openbao/pki-role.d.ts.map +1 -1
  94. package/dist/openbao/plugin-reconcile.d.ts +6 -2
  95. package/dist/openbao/plugin-reconcile.d.ts.map +1 -1
  96. package/dist/openbao/plugin.d.ts.map +1 -1
  97. package/dist/openbao/policy.d.ts.map +1 -1
  98. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  99. package/dist/openbao/rename-identity.d.ts +69 -0
  100. package/dist/openbao/rename-identity.d.ts.map +1 -0
  101. package/dist/openbao/rename.d.ts +13 -12
  102. package/dist/openbao/rename.d.ts.map +1 -1
  103. package/dist/openbao/role-reconcile.d.ts +11 -1
  104. package/dist/openbao/role-reconcile.d.ts.map +1 -1
  105. package/dist/openbao/ssh-role-erase.d.ts +11 -0
  106. package/dist/openbao/ssh-role-erase.d.ts.map +1 -0
  107. package/dist/openbao/ssh-role.d.ts.map +1 -1
  108. package/dist/ownership/adopt.d.ts +52 -0
  109. package/dist/ownership/adopt.d.ts.map +1 -0
  110. package/dist/ownership/probe.d.ts +13 -0
  111. package/dist/ownership/probe.d.ts.map +1 -0
  112. package/dist/ownership/resume.d.ts +40 -0
  113. package/dist/ownership/resume.d.ts.map +1 -0
  114. package/dist/ownership/rows.d.ts +33 -0
  115. package/dist/ownership/rows.d.ts.map +1 -0
  116. package/dist/ownership/whole.d.ts +14 -0
  117. package/dist/ownership/whole.d.ts.map +1 -0
  118. package/dist/proxmox/ceph-pool-form.d.ts +3 -3
  119. package/dist/proxmox/ceph-pool.d.ts.map +1 -1
  120. package/dist/proxmox/fake-bao-env.d.ts +3 -0
  121. package/dist/proxmox/fake-bao-env.d.ts.map +1 -0
  122. package/dist/proxmox/fake-pve-lxc.d.ts +42 -0
  123. package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -0
  124. package/dist/proxmox/fake-pve.d.ts +38 -0
  125. package/dist/proxmox/fake-pve.d.ts.map +1 -0
  126. package/dist/proxmox/index.d.ts +6 -2
  127. package/dist/proxmox/index.d.ts.map +1 -1
  128. package/dist/proxmox/index.js +970 -186
  129. package/dist/proxmox/index.js.map +22 -9
  130. package/dist/proxmox/lxc-create-form.d.ts +6 -0
  131. package/dist/proxmox/lxc-create-form.d.ts.map +1 -0
  132. package/dist/proxmox/lxc-harness.d.ts +34 -0
  133. package/dist/proxmox/lxc-harness.d.ts.map +1 -0
  134. package/dist/proxmox/lxc-identity.d.ts +18 -0
  135. package/dist/proxmox/lxc-identity.d.ts.map +1 -0
  136. package/dist/proxmox/lxc-judge.d.ts +46 -0
  137. package/dist/proxmox/lxc-judge.d.ts.map +1 -0
  138. package/dist/proxmox/lxc-lifecycle.d.ts +68 -0
  139. package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -0
  140. package/dist/proxmox/lxc-props.d.ts +139 -0
  141. package/dist/proxmox/lxc-props.d.ts.map +1 -0
  142. package/dist/proxmox/lxc-volume.d.ts +29 -0
  143. package/dist/proxmox/lxc-volume.d.ts.map +1 -0
  144. package/dist/proxmox/lxc-wire.d.ts +51 -0
  145. package/dist/proxmox/lxc-wire.d.ts.map +1 -0
  146. package/dist/proxmox/lxc.d.ts +27 -47
  147. package/dist/proxmox/lxc.d.ts.map +1 -1
  148. package/dist/proxmox/network-apply-read.d.ts +1 -1
  149. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  150. package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
  151. package/dist/proxmox/resource.d.ts.map +1 -1
  152. package/dist/proxmox/update-guard.d.ts +27 -0
  153. package/dist/proxmox/update-guard.d.ts.map +1 -0
  154. package/dist/verify/args.d.ts +16 -0
  155. package/dist/verify/args.d.ts.map +1 -0
  156. package/dist/verify/cli.d.ts +3 -0
  157. package/dist/verify/cli.d.ts.map +1 -0
  158. package/dist/verify/cli.js +320 -0
  159. package/dist/verify/cli.js.map +15 -0
  160. package/dist/verify/fake-engine.d.ts +57 -0
  161. package/dist/verify/fake-engine.d.ts.map +1 -0
  162. package/dist/verify/index.d.ts +16 -0
  163. package/dist/verify/index.d.ts.map +1 -0
  164. package/dist/verify/index.js +297 -0
  165. package/dist/verify/index.js.map +14 -0
  166. package/dist/verify/report.d.ts +6 -0
  167. package/dist/verify/report.d.ts.map +1 -0
  168. package/dist/verify/rows.d.ts +57 -0
  169. package/dist/verify/rows.d.ts.map +1 -0
  170. package/dist/verify/spy.d.ts +31 -0
  171. package/dist/verify/spy.d.ts.map +1 -0
  172. package/dist/verify/verify.d.ts +43 -0
  173. package/dist/verify/verify.d.ts.map +1 -0
  174. package/docs/adopt-verify.md +108 -0
  175. package/docs/adopted-deploys.md +85 -0
  176. package/docs/caddy-admin.md +24 -0
  177. package/docs/caddy.md +73 -54
  178. package/docs/credentials.md +15 -0
  179. package/docs/launchd-sudo.md +147 -0
  180. package/docs/launchd.md +14 -7
  181. package/docs/ownership.md +113 -0
  182. package/docs/peers.md +39 -0
  183. package/docs/proxmox-lxc.md +196 -0
  184. package/docs/proxmox.md +12 -0
  185. package/package.json +9 -2
  186. package/src/caddy/adopt-scope.test.ts +82 -0
  187. package/src/caddy/config-adopt.test.ts +235 -0
  188. package/src/caddy/config-lifecycle.test.ts +31 -53
  189. package/src/caddy/config-lifecycle.ts +98 -7
  190. package/src/caddy/config.ts +48 -10
  191. package/src/caddy/fake-caddy.ts +19 -0
  192. package/src/caddy/index.ts +2 -1
  193. package/src/caddy/providers.test.ts +8 -23
  194. package/src/caddy/with-file.ts +7 -3
  195. package/src/launchd/adopt-apply.test.ts +166 -0
  196. package/src/launchd/fake-runner.ts +26 -4
  197. package/src/launchd/fake-sudo.ts +136 -0
  198. package/src/launchd/host-effect.ts +1 -1
  199. package/src/launchd/host-file-form.ts +10 -2
  200. package/src/launchd/host-file-lifecycle.ts +41 -15
  201. package/src/launchd/host-file.ts +13 -4
  202. package/src/launchd/index.ts +2 -0
  203. package/src/launchd/job-lifecycle.ts +22 -14
  204. package/src/launchd/job-preflight.ts +21 -3
  205. package/src/launchd/job-validate.ts +1 -1
  206. package/src/launchd/job.ts +13 -4
  207. package/src/launchd/local-runner.ts +6 -3
  208. package/src/launchd/providers.test.ts +3 -0
  209. package/src/launchd/providers.ts +2 -1
  210. package/src/launchd/runner.ts +14 -5
  211. package/src/launchd/sudo-acl.ts +68 -0
  212. package/src/launchd/sudo-allowlist.test.ts +191 -0
  213. package/src/launchd/sudo-allowlist.ts +213 -0
  214. package/src/launchd/sudo-chain.test.ts +121 -0
  215. package/src/launchd/sudo-guard.test.ts +100 -0
  216. package/src/launchd/sudo-guard.ts +230 -0
  217. package/src/launchd/sudo-lifecycle.test.ts +172 -0
  218. package/src/launchd/sudo-modes.test.ts +135 -0
  219. package/src/launchd/sudo-runner-files.test.ts +240 -0
  220. package/src/launchd/sudo-runner.test.ts +126 -0
  221. package/src/launchd/sudo-runner.ts +216 -0
  222. package/src/launchd/sudo-said.ts +58 -0
  223. package/src/launchd/sudo-stage.ts +28 -0
  224. package/src/openbao/README.md +11 -2
  225. package/src/openbao/REPLACE.md +67 -37
  226. package/src/openbao/adopt-core.test.ts +119 -0
  227. package/src/openbao/adopt-families.test.ts +97 -0
  228. package/src/openbao/adopt-holes.test.ts +168 -0
  229. package/src/openbao/adopt-recovery.test.ts +71 -0
  230. package/src/openbao/auth-method-reconcile.ts +15 -3
  231. package/src/openbao/auth-method.ts +15 -6
  232. package/src/openbao/auth-role-form.test.ts +2 -16
  233. package/src/openbao/auth-role-form.ts +0 -11
  234. package/src/openbao/auth-role.ts +48 -17
  235. package/src/openbao/cloudflare-role.ts +23 -18
  236. package/src/openbao/fake-bao.ts +52 -0
  237. package/src/openbao/fake-core.ts +141 -0
  238. package/src/openbao/fake-engines-roles.ts +114 -0
  239. package/src/openbao/fake-engines.ts +18 -11
  240. package/src/openbao/fake-families.ts +188 -0
  241. package/src/openbao/fake-mounts.ts +74 -0
  242. package/src/openbao/fake-stack.ts +17 -4
  243. package/src/openbao/host-approles.test.ts +15 -2
  244. package/src/openbao/host-approles.ts +19 -4
  245. package/src/openbao/jwt-config.ts +35 -11
  246. package/src/openbao/jwt-role.ts +33 -14
  247. package/src/openbao/kubernetes-role.ts +32 -14
  248. package/src/openbao/mfa-enforcement.ts +42 -13
  249. package/src/openbao/mfa-totp.ts +42 -14
  250. package/src/openbao/mount-move.test.ts +2 -35
  251. package/src/openbao/mount-reconcile.ts +13 -2
  252. package/src/openbao/mount.ts +15 -6
  253. package/src/openbao/pki-role.ts +33 -10
  254. package/src/openbao/plugin-reconcile.ts +16 -5
  255. package/src/openbao/plugin.ts +57 -20
  256. package/src/openbao/policy.ts +24 -11
  257. package/src/openbao/proxmox-role.ts +20 -20
  258. package/src/openbao/rename-adoption.test.ts +95 -0
  259. package/src/openbao/rename-families.test.ts +122 -0
  260. package/src/openbao/rename-identity.test.ts +129 -0
  261. package/src/openbao/rename-identity.ts +117 -0
  262. package/src/openbao/rename.ts +19 -15
  263. package/src/openbao/role-reconcile.ts +23 -0
  264. package/src/openbao/ssh-role-erase.ts +50 -0
  265. package/src/openbao/ssh-role.ts +29 -57
  266. package/src/ownership/adopt.ts +122 -0
  267. package/src/ownership/ownership.test.ts +200 -0
  268. package/src/ownership/probe.ts +65 -0
  269. package/src/ownership/resume.ts +118 -0
  270. package/src/ownership/rows.ts +117 -0
  271. package/src/ownership/whole.test.ts +39 -0
  272. package/src/ownership/whole.ts +60 -0
  273. package/src/proxmox/adopt-noop.test.ts +112 -0
  274. package/src/proxmox/ceph-pool-adopt.test.ts +88 -0
  275. package/src/proxmox/ceph-pool-form.ts +3 -3
  276. package/src/proxmox/ceph-pool.ts +10 -3
  277. package/src/proxmox/fake-bao-env.ts +28 -0
  278. package/src/proxmox/fake-pve-lxc.ts +210 -0
  279. package/src/proxmox/fake-pve.ts +75 -0
  280. package/src/proxmox/index.test.ts +6 -6
  281. package/src/proxmox/index.ts +6 -2
  282. package/src/proxmox/lxc-adopt.test.ts +170 -0
  283. package/src/proxmox/lxc-create-form.ts +76 -0
  284. package/src/proxmox/lxc-create.test.ts +126 -0
  285. package/src/proxmox/lxc-guards.test.ts +158 -0
  286. package/src/proxmox/lxc-harness.ts +150 -0
  287. package/src/proxmox/lxc-identity.ts +43 -0
  288. package/src/proxmox/lxc-judge.test.ts +175 -0
  289. package/src/proxmox/lxc-judge.ts +196 -0
  290. package/src/proxmox/lxc-lifecycle.ts +200 -0
  291. package/src/proxmox/lxc-ownership.test.ts +159 -0
  292. package/src/proxmox/lxc-props.ts +215 -0
  293. package/src/proxmox/lxc-read.test.ts +60 -0
  294. package/src/proxmox/lxc-volume.test.ts +96 -0
  295. package/src/proxmox/lxc-volume.ts +153 -0
  296. package/src/proxmox/lxc-wire.ts +210 -0
  297. package/src/proxmox/lxc.ts +216 -93
  298. package/src/proxmox/network-apply-read.ts +3 -1
  299. package/src/proxmox/pbs-datastore.ts +4 -3
  300. package/src/proxmox/resource.ts +7 -7
  301. package/src/proxmox/update-guard.ts +31 -0
  302. package/src/verify/args.ts +82 -0
  303. package/src/verify/cli.test.ts +86 -0
  304. package/src/verify/cli.ts +49 -0
  305. package/src/verify/fake-engine.ts +158 -0
  306. package/src/verify/index.ts +36 -0
  307. package/src/verify/report.ts +52 -0
  308. package/src/verify/rows.ts +169 -0
  309. package/src/verify/spy.test.ts +141 -0
  310. package/src/verify/spy.ts +149 -0
  311. package/src/verify/verify.test.ts +124 -0
  312. package/src/verify/verify.ts +172 -0
@@ -43,8 +43,10 @@ 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
- import { mountPath } from './mount-form.ts';
48
50
  import {
49
51
  type BaoPkiRoleAttributes,
50
52
  type BaoPkiRoleProps,
@@ -54,6 +56,7 @@ import {
54
56
  rolePath,
55
57
  writeBody,
56
58
  } from './pki-role-form.ts';
59
+ import { guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
57
60
 
58
61
  export type { BaoPkiRoleAttributes, BaoPkiRoleProps };
59
62
 
@@ -69,6 +72,13 @@ export const BaoPkiRole = Resource<BaoPkiRole>('Bao.PkiRole', {
69
72
  defaultRemovalPolicy: 'retain',
70
73
  });
71
74
 
75
+ /** Exact: the engine stores `role/<name>` verbatim (builtin/logical/pki/path_roles.go:963). */
76
+ const IDENTITY = roleIdentity<BaoPkiRoleAttributes>(
77
+ 'Bao.PkiRole',
78
+ (mount, name) => rolePath({ mount, name }),
79
+ 'pki',
80
+ );
81
+
72
82
  const readRole = (props: BaoPkiRoleProps) =>
73
83
  Effect.gen(function* () {
74
84
  const live = yield* baoRead(rolePath(props));
@@ -88,8 +98,13 @@ export const BaoPkiRoleProvider = () =>
88
98
  */
89
99
  list: () => Effect.succeed([]),
90
100
 
91
- read: Effect.fn(function* ({ olds }) {
92
- 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);
93
108
  }),
94
109
 
95
110
  /**
@@ -100,18 +115,22 @@ export const BaoPkiRoleProvider = () =>
100
115
  * fields is also what stops a field being added to props and quietly forgotten in
101
116
  * the comparison.
102
117
  */
103
- diff: Effect.fn(function* ({ news, output }) {
104
- if (output === undefined || !isResolved(news)) return undefined;
118
+ diff: Effect.fn(function* ({ news, olds, output }) {
105
119
  /**
106
120
  * ⚠️ A ROLE IS IDENTIFIED BY MOUNT **AND** NAME. Moving a declaration to another
107
121
  * engine is a different object under a different CA, never an in-place edit —
108
- * `replace`, so Alchemy creates the new one and retires the old.
122
+ * `replace`, so Alchemy creates the new one and retires the old. Judged before
123
+ * `isResolved(news)`, and ⛔ a move onto a role that exists fails the plan
124
+ * (rename-identity.ts).
109
125
  * ⛔ A RENAME WAS NOT CAUGHT UNTIL 2026-09-21 (REPLACE.md): only the mount was compared,
110
126
  * so a new `name` read nothing at the new path, planned `update`, wrote the new role,
111
127
  * and left the old one issuing certificates under no state record at all.
112
128
  */
113
- if (mountPath(news.mount ?? 'pki') !== output.mount || news.name !== output.name)
114
- return { action: 'replace' } as const;
129
+ const move = yield* judgeRename(IDENTITY, olds, news, output);
130
+ // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
131
+ if (output === undefined) return undefined;
132
+ if (move !== undefined) return { action: 'replace' } as const;
133
+ if (!isResolved(news)) return undefined;
115
134
  /**
116
135
  * ⚠️ A DECLARATION reconcile WOULD REFUSE MUST NEVER PLAN AS noop. An empty
117
136
  * allowedDomains, or a duration OpenBao cannot parse, can compare equal to a
@@ -127,8 +146,10 @@ export const BaoPkiRoleProvider = () =>
127
146
  : ({ action: 'update' } as const);
128
147
  }),
129
148
 
130
- reconcile: Effect.fn(function* ({ news }) {
149
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
131
150
  const path = rolePath(news);
151
+ // ⛔ An `update` across a move the diff could not see — refused before any write.
152
+ yield* guardRename(IDENTITY, news, output);
132
153
  /**
133
154
  * ⛔ AN EMPTY allowedDomains IS A REFUSAL, NOT AN EMPTY ROLE. `allow_any_name` is
134
155
  * false on every live role, so a role with no allowed domains can issue NOTHING —
@@ -141,6 +162,8 @@ export const BaoPkiRoleProvider = () =>
141
162
  return yield* Effect.die(new Error(`Bao.PkiRole ${path}: ${bad.join(' ')}`));
142
163
  }
143
164
  const live = yield* readRole(news);
165
+ if (live !== undefined)
166
+ yield* refuseTakeover({ fqn, instanceId, output }, `Bao.PkiRole ${path}`);
144
167
  if (live === undefined || !matches(live, news)) {
145
168
  yield* baoWrite('PUT', path, writeBody(news));
146
169
  }
@@ -183,5 +206,5 @@ export const BaoPkiRoleProvider = () =>
183
206
  return undefined;
184
207
  }),
185
208
  }),
186
- ),
209
+ ).pipe(Effect.map(provingResumes)),
187
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,9 +24,22 @@ 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 { type BaoPluginAttributes, type BaoPluginProps, matches, resolve } from './plugin-form.ts';
27
+ import { claimFor } from '../ownership/adopt.ts';
28
+ import { ownedRead } from '../ownership/probe.ts';
29
+ import { provingResumes } from '../ownership/resume.ts';
30
+ import {
31
+ type BaoPluginAttributes,
32
+ type BaoPluginProps,
33
+ type BaoPluginType,
34
+ matches,
35
+ problems,
36
+ resolve,
37
+ versionedPath,
38
+ } from './plugin-form.ts';
28
39
  import { readEntry, reconcilePlugin } from './plugin-reconcile.ts';
29
40
  import { deregisterPlugin } from './plugin-wire.ts';
41
+ import { declaredOr, declaredString } from './rename.ts';
42
+ import { type Identity, guardRename, judgeRename } from './rename-identity.ts';
30
43
 
31
44
  export type { BaoPluginAttributes, BaoPluginProps, BaoPluginType } from './plugin-form.ts';
32
45
 
@@ -42,6 +55,24 @@ export const BaoPlugin = Resource<BaoPlugin>('Bao.Plugin', {
42
55
  defaultRemovalPolicy: 'retain',
43
56
  });
44
57
 
58
+ /**
59
+ * ⛔ NAME, TYPE AND VERSION ARE THE CATALOG KEY, so the identity is the versioned path a read and a
60
+ * delete address. Exact: the catalog stores `<type>/<name>/<version>` as given, and the version
61
+ * is already canonical (`problems`). ⚠️ An unversioned name that is a builtin reads back as
62
+ * present (plugin-wire.ts), so a move onto one fails the plan, as reconcile would refuse anyway.
63
+ */
64
+ const IDENTITY: Identity<BaoPluginAttributes> = {
65
+ declared: (props) => {
66
+ const type = declaredString(props, 'type') as BaoPluginType | undefined;
67
+ const name = declaredString(props, 'name');
68
+ const version = declaredOr(props, 'version', '');
69
+ if (type === undefined || name === undefined || version === undefined) return undefined;
70
+ return versionedPath(type, name, version);
71
+ },
72
+ family: 'Bao.Plugin',
73
+ recorded: (output) => versionedPath(output.type, output.name, output.version),
74
+ };
75
+
45
76
  export const BaoPluginProvider = () =>
46
77
  Provider.effect(
47
78
  BaoPlugin,
@@ -54,27 +85,31 @@ export const BaoPluginProvider = () =>
54
85
  */
55
86
  list: () => Effect.succeed([]),
56
87
 
57
- read: Effect.fn(function* ({ olds }) {
58
- const found = yield* readEntry(resolve(olds));
59
- 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);
60
97
  }),
61
98
 
62
99
  /** ⛔ IT COMPARES THE LIVE ENTRY, NOT THE STORED DIGEST — a hand re-register is drift. */
63
- diff: Effect.fn(function* ({ news, output }) {
64
- if (output === undefined || !isResolved(news)) return undefined;
65
- const form = resolve(news);
100
+ diff: Effect.fn(function* ({ news, olds, output }) {
66
101
  /**
67
- * ⛔ NAME, TYPE AND VERSION ARE THE CATALOG KEY, so changing one is a different entry,
68
- * not an edit. Writing the new key would leave the old registration in place under a
69
- * state record that no longer names it.
102
+ * ⛔ A NEW NAME, TYPE OR VERSION IS A DIFFERENT ENTRY, not an edit: `replace`, judged
103
+ * before `isResolved(news)`. Writing the new key in place would leave the old
104
+ * registration under a state record that no longer names it. ⛔ A move onto an entry
105
+ * that is already registered fails the plan (rename-identity.ts).
70
106
  */
71
- if (
72
- output.name !== form.name ||
73
- output.type !== form.type ||
74
- output.version !== form.version
75
- ) {
76
- return { action: 'replace' } as const;
77
- }
107
+ const move = yield* judgeRename(IDENTITY, olds, news, output);
108
+ // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
109
+ if (output === undefined) return undefined;
110
+ if (move !== undefined) return { action: 'replace' } as const;
111
+ if (!isResolved(news)) return undefined;
112
+ const form = resolve(news);
78
113
  const found = yield* readEntry(form);
79
114
  if (found === undefined) return { action: 'update' } as const;
80
115
  return matches(found.attributes, form)
@@ -83,8 +118,10 @@ export const BaoPluginProvider = () =>
83
118
  }),
84
119
 
85
120
  /** The refusals — declarative, builtin, self-reported version — live in plugin-reconcile.ts. */
86
- reconcile: Effect.fn(function* ({ news }) {
87
- return yield* reconcilePlugin(news);
121
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
122
+ // ⛔ An `update` across a move the diff could not see — refused before any write.
123
+ yield* guardRename(IDENTITY, news, output);
124
+ return yield* reconcilePlugin(news, claimFor({ fqn, instanceId, output }));
88
125
  }),
89
126
 
90
127
  /**
@@ -96,5 +133,5 @@ export const BaoPluginProvider = () =>
96
133
  return undefined;
97
134
  }),
98
135
  }),
99
- ),
136
+ ).pipe(Effect.map(provingResumes)),
100
137
  );
@@ -5,10 +5,14 @@ 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';
11
- import { declaredString, isMoved, judgeMove, policyKey, refuseMovedUpdate } from './rename.ts';
14
+ import { policyKey } from './rename.ts';
15
+ import { guardRename, judgeRename, nameIdentity } from './rename-identity.ts';
12
16
 
13
17
  /**
14
18
  * An OpenBao ACL policy, assembled from the HCL fragments that declare it.
@@ -99,6 +103,9 @@ export const BaoPolicy = Resource<BaoPolicy>('Bao.Policy', {
99
103
  defaultRemovalPolicy: 'retain',
100
104
  });
101
105
 
106
+ /** A policy is its name, keyed as OpenBao keys it (`policyKey`). */
107
+ const IDENTITY = nameIdentity<BaoPolicyAttributes>('Bao.Policy', policyPath, policyKey);
108
+
102
109
  /** `path "..."` grant count — the same number the apply script prints. */
103
110
  const grantsOf = (hcl: string) => (hcl.match(/^path /gm) ?? []).length;
104
111
 
@@ -134,9 +141,16 @@ export const BaoPolicyProvider = () =>
134
141
  list: () => Effect.succeed([]),
135
142
 
136
143
  // ★ '' means genuinely absent (a 404); a refusal fails. policy-wire.ts has the history.
137
- 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 }) {
138
147
  const live = yield* readPolicy(olds.name);
139
- 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);
140
154
  }),
141
155
 
142
156
  /**
@@ -156,9 +170,8 @@ export const BaoPolicyProvider = () =>
156
170
  * delete, so a role outside this graph that names the old policy must move in the same
157
171
  * PR (REPLACE.md).
158
172
  */
159
- const tried = output?.name ?? declaredString(olds, 'name');
160
- const declared = declaredString(news, 'name');
161
- const move = yield* judgeMove('Bao.Policy', tried, declared, policyPath, policyKey);
173
+ const move = yield* judgeRename(IDENTITY, olds, news, output);
174
+ // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
162
175
  if (output === undefined) return undefined;
163
176
  if (move !== undefined) return { action: 'replace' } as const;
164
177
  // ⚠️ A prop can still be an unresolved Output or Config at plan time. Docker's
@@ -172,12 +185,10 @@ export const BaoPolicyProvider = () =>
172
185
  : ({ action: 'update' } as const);
173
186
  }),
174
187
 
175
- reconcile: Effect.fn(function* ({ news, output }) {
188
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
176
189
  const name = news.name;
177
190
  // ⛔ An `update` across a rename the diff could not see — refused before any write.
178
- if (output !== undefined && isMoved(output.name, name, policyKey) === true) {
179
- return yield* refuseMovedUpdate(`Bao.Policy ${name}`, output.name, name);
180
- }
191
+ yield* guardRename(IDENTITY, news, output);
181
192
  const { joined, parts } = yield* assemble(news.fragments);
182
193
  /**
183
194
  * ⛔ AN EMPTY ASSEMBLY IS A REFUSAL, NOT AN EMPTY POLICY. `bao policy write` with
@@ -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,
@@ -50,15 +53,8 @@ import {
50
53
  writeBody,
51
54
  } from './proxmox-role-form.ts';
52
55
  import { readRole } from './proxmox-role-wire.ts';
53
- import {
54
- declaredRolePath,
55
- declaredString,
56
- isMoved,
57
- isPendingProp,
58
- judgeMove,
59
- refuseMovedUpdate,
60
- triedRolePath,
61
- } from './rename.ts';
56
+ import { declaredString, isPendingProp } from './rename.ts';
57
+ import { guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
62
58
 
63
59
  export type { BaoProxmoxRoleAttributes, BaoProxmoxRoleProps };
64
60
 
@@ -74,6 +70,9 @@ export const BaoProxmoxRole = Resource<BaoProxmoxRole>('Bao.ProxmoxRole', {
74
70
  defaultRemovalPolicy: 'retain',
75
71
  });
76
72
 
73
+ /** Exact: the plugin stores `roles/<name>` verbatim. */
74
+ const IDENTITY = roleIdentity<BaoProxmoxRoleAttributes>('Bao.ProxmoxRole', rolePath);
75
+
77
76
  export const BaoProxmoxRoleProvider = () =>
78
77
  Provider.effect(
79
78
  BaoProxmoxRole,
@@ -88,8 +87,11 @@ export const BaoProxmoxRoleProvider = () =>
88
87
  */
89
88
  list: () => Effect.succeed([]),
90
89
 
91
- read: Effect.fn(function* ({ olds }) {
92
- 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);
93
95
  }),
94
96
 
95
97
  /**
@@ -110,9 +112,8 @@ export const BaoProxmoxRoleProvider = () =>
110
112
  * still an Output it cannot be checked, so the diff defers, and reconcile refuses the
111
113
  * resulting `update`.
112
114
  */
113
- const tried = triedRolePath(output, olds, rolePath);
114
- const declared = declaredRolePath(news, rolePath);
115
- const move = yield* judgeMove('Bao.ProxmoxRole', tried, declared, (path) => path);
115
+ const move = yield* judgeRename(IDENTITY, olds, news, output);
116
+ // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
116
117
  if (output === undefined) return undefined;
117
118
  if (move !== undefined) {
118
119
  const mintUser = declaredString(news, 'mintUser');
@@ -148,14 +149,13 @@ export const BaoProxmoxRoleProvider = () =>
148
149
  return { action: 'update' } as const;
149
150
  }),
150
151
 
151
- reconcile: Effect.fn(function* ({ news, output }) {
152
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
152
153
  const path = rolePath(news.mount, news.name);
153
154
  // ⛔ An `update` across a move the diff could not see — refused before any read or write.
154
- const before = output === undefined ? path : rolePath(output.mount, output.name);
155
- if (isMoved(before, path) === true) {
156
- return yield* refuseMovedUpdate('Bao.ProxmoxRole', before, path);
157
- }
155
+ yield* guardRename(IDENTITY, news, output);
158
156
  const live = yield* readRole(news);
157
+ if (live !== undefined)
158
+ yield* refuseTakeover({ fqn, instanceId, output }, `Bao.ProxmoxRole ${path}`);
159
159
  /**
160
160
  * ⛔ A mint_user CHANGE RE-SCOPES EVERY CREDENTIAL THE ROLE WILL EVER MINT, SILENTLY.
161
161
  * The minted token inherits that PVE user's standing ACL and nothing else, so
@@ -235,5 +235,5 @@ export const BaoProxmoxRoleProvider = () =>
235
235
  return undefined;
236
236
  }),
237
237
  }),
238
- ),
238
+ ).pipe(Effect.map(provingResumes)),
239
239
  );
@@ -0,0 +1,95 @@
1
+ /**
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
+ *
14
+ * ⛔ TEST-ONLY. Every value is a placeholder, not the estate's.
15
+ */
16
+ import assert from 'node:assert/strict';
17
+ import { describe, it } from 'node:test';
18
+ import { renamedFrom } from 'alchemy';
19
+ import * as RemovalPolicy from 'alchemy/RemovalPolicy';
20
+ import * as Effect from 'effect/Effect';
21
+ import { FAMILIES, rowOf, withEstate } from './fake-families.ts';
22
+ import { writesOf } from './fake-stack.ts';
23
+
24
+ const { destroy } = RemovalPolicy;
25
+
26
+ /** No delete endpoint (jwt-config.ts), and a delete the provider refuses (mfa-enforcement.ts). */
27
+ const KEEPS = new Set(['Bao.JwtAuthConfig', 'Bao.MfaLoginEnforcement']);
28
+ const REFUSES_DELETE = /Bao\.MfaLoginEnforcement a: REFUSING to delete/;
29
+ const NOT_OWNED = /Cannot adopt resource 'Z'.*--adopt/s;
30
+
31
+ describe('a logical id changed without renamedFrom, under destroy', () => {
32
+ for (const row of FAMILIES) {
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 () => {
44
+ await withEstate(async (stack, estate) => {
45
+ await stack.deploy(row.declare('X', 'a', '15m', destroy));
46
+ const again = stack.deploy(row.declare('Z', 'a', '15m', destroy), { adopt: true });
47
+ if (row.family === 'Bao.MfaLoginEnforcement') await assert.rejects(again, REFUSES_DELETE);
48
+ else assert.deepEqual(await again, { X: 'delete', Z: 'adopted' });
49
+ assert.equal(row.has(estate, 'a'), KEEPS.has(row.family));
50
+ });
51
+ });
52
+ }
53
+ });
54
+
55
+ describe('the ways round it', () => {
56
+ const row = rowOf('Bao.AuthRole');
57
+
58
+ it('renamedFrom migrates the state row instead: an update that deletes nothing', async () => {
59
+ await withEstate(async (stack, estate, seen) => {
60
+ await stack.deploy(row.declare('X', 'a', '15m', destroy));
61
+ seen.length = 0;
62
+ const moved = row.declare('Z', 'a', '15m', destroy).pipe(renamedFrom('X'));
63
+ assert.deepEqual(await stack.deploy(moved), { Z: 'update' });
64
+ assert.deepEqual(writesOf(seen), []);
65
+ assert.ok(row.has(estate, 'a'));
66
+ });
67
+ });
68
+
69
+ it('a name another resource moves off is refused when taken in the same deploy', async () => {
70
+ await withEstate(async (stack, estate, seen) => {
71
+ await stack.deploy(row.declare('X', 'a', '15m', destroy));
72
+ seen.length = 0;
73
+ const both = Effect.gen(function* () {
74
+ yield* row.declare('X', 'b', '15m', destroy);
75
+ yield* row.declare('Z', 'a', '30m', destroy);
76
+ });
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'));
80
+ });
81
+ });
82
+
83
+ it('and succeeds when taken in the deploy after the move', async () => {
84
+ await withEstate(async (stack, estate) => {
85
+ await stack.deploy(row.declare('X', 'a', '15m', destroy));
86
+ await stack.deploy(row.declare('X', 'b', '15m', destroy));
87
+ const both = Effect.gen(function* () {
88
+ yield* row.declare('X', 'b', '15m', destroy);
89
+ yield* row.declare('Z', 'a', '30m', destroy);
90
+ });
91
+ assert.deepEqual(await stack.deploy(both), { X: 'noop', Z: 'create' });
92
+ assert.ok(row.has(estate, 'a') && row.has(estate, 'b'));
93
+ });
94
+ });
95
+ });