@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
@@ -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,
@@ -43,12 +46,13 @@ import {
43
46
  type BaoSshRoleProps,
44
47
  attributesOf,
45
48
  badDurations,
46
- extMap,
47
49
  matches,
48
50
  readPath,
49
51
  resolve,
50
52
  writeBody,
51
53
  } from './ssh-role-form.ts';
54
+ import { wouldErase } from './ssh-role-erase.ts';
55
+ import { guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
52
56
 
53
57
  export type { BaoSshRoleAttributes, BaoSshRoleProps };
54
58
 
@@ -64,6 +68,9 @@ export const BaoSshRole = Resource<BaoSshRole>('Bao.SshRole', {
64
68
  defaultRemovalPolicy: 'retain',
65
69
  });
66
70
 
71
+ /** Exact: the engine stores `roles/<name>` verbatim (builtin/logical/ssh/path_roles.go:602). */
72
+ const IDENTITY = roleIdentity<BaoSshRoleAttributes>('Bao.SshRole', readPath, 'ssh');
73
+
67
74
  /** Live role, plus the raw body — reconcile needs the raw half to see what it would erase. */
68
75
  const readRole = (form: BaoSshRoleForm) =>
69
76
  Effect.gen(function* () {
@@ -72,51 +79,6 @@ const readRole = (form: BaoSshRoleForm) =>
72
79
  return { attributes: attributesOf(form, live), live };
73
80
  });
74
81
 
75
- /**
76
- * Fields this resource deliberately does not model, mapped to the value OpenBao's own write
77
- * puts there when nobody says otherwise. The templating flags change how a principal is
78
- * DERIVED at signing time, which is a different kind of decision from which principals
79
- * exist; the rest are issuance mechanics. MEASURED against all three live roles — every one
80
- * of them sits on exactly these values, so the guard below fires on nothing that exists.
81
- */
82
- const UNMANAGED_DEFAULTS: Readonly<Record<string, unknown>> = {
83
- algorithm_signer: 'default',
84
- allow_commas_in_identity_templates: false,
85
- allow_user_key_ids: false,
86
- allowed_domains_template: false,
87
- allowed_users_template: false,
88
- default_extensions_template: false,
89
- default_user_template: false,
90
- issuer_ref: 'default',
91
- key_id_format: '',
92
- not_before_duration: 30,
93
- };
94
-
95
- /**
96
- * ⚠️ ABSENT AND EMPTY BOTH COUNT AS "NEVER SET". A role this resource wrote omits these
97
- * fields entirely, so a stricter test would refuse on the provider's own output and
98
- * deadlock the update after next.
99
- */
100
- const isDefault = (live: Record<string, unknown>, field: string) =>
101
- live[field] === undefined || live[field] === '' || live[field] === UNMANAGED_DEFAULTS[field];
102
-
103
- /**
104
- * ⛔ THE FIELDS A WRITE WOULD SILENTLY DESTROY. Because the write is a full replace, a role
105
- * someone tuned by hand — `allowed_user_key_lengths` (a minimum RSA size), a
106
- * `key_id_format` the audit trail is grepped by, a `not_before_duration` widened to
107
- * survive clock skew, a non-default issuer — loses all of it the moment this resource
108
- * decides to update. A vanished key-length floor is a security regression that no plan
109
- * output would ever mention, and a vanished backdating window breaks signing on exactly
110
- * the skewed host it was added for. So reconcile refuses instead of writing.
111
- */
112
- const wouldErase = (live: Record<string, unknown>): readonly string[] => {
113
- const lost = Object.keys(UNMANAGED_DEFAULTS).filter((field) => !isDefault(live, field));
114
- if (Object.keys(extMap(live['allowed_user_key_lengths'])).length > 0) {
115
- lost.push('allowed_user_key_lengths');
116
- }
117
- return lost.sort();
118
- };
119
-
120
82
  const refuse = (message: string) => Effect.die(new Error(message));
121
83
 
122
84
  export const BaoSshRoleProvider = () =>
@@ -132,9 +94,13 @@ export const BaoSshRoleProvider = () =>
132
94
  */
133
95
  list: () => Effect.succeed([]),
134
96
 
135
- 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 }) {
136
99
  const found = yield* readRole(resolve(olds));
137
- 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);
138
104
  }),
139
105
 
140
106
  /**
@@ -147,18 +113,20 @@ export const BaoSshRoleProvider = () =>
147
113
  * at risk when something actually writes; if every managed field already matches,
148
114
  * the honest answer is `noop` and nothing gets destroyed.
149
115
  */
150
- diff: Effect.fn(function* ({ news, output }) {
151
- if (output === undefined || !isResolved(news)) return undefined;
152
- const form = resolve(news);
116
+ diff: Effect.fn(function* ({ news, olds, output }) {
153
117
  /**
154
118
  * ⛔ A RENAMED ROLE IS A NEW PATH, NOT AN EDIT. `ssh/roles/x` and `ssh-host/roles/x`
155
119
  * are different mounts with different CAs. Without this, changing `name` or
156
120
  * `mount` would write the new role and leave the old one signing certificates
157
- * for as long as anyone remembered its name.
121
+ * for as long as anyone remembered its name. Judged before `isResolved(news)`, and
122
+ * ⛔ a move onto a role that exists fails the plan (rename-identity.ts).
158
123
  */
159
- if (output.mount !== form.mount || output.name !== form.name) {
160
- return { action: 'replace' } as const;
161
- }
124
+ const move = yield* judgeRename(IDENTITY, olds, news, output);
125
+ // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
126
+ if (output === undefined) return undefined;
127
+ if (move !== undefined) return { action: 'replace' } as const;
128
+ if (!isResolved(news)) return undefined;
129
+ const form = resolve(news);
162
130
  const found = yield* readRole(form);
163
131
  if (found === undefined) return { action: 'update' } as const;
164
132
  return matches(found.attributes, form)
@@ -166,9 +134,11 @@ export const BaoSshRoleProvider = () =>
166
134
  : ({ action: 'update' } as const);
167
135
  }),
168
136
 
169
- reconcile: Effect.fn(function* ({ news }) {
137
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
170
138
  const form = resolve(news);
171
139
  const path = readPath(form.mount, form.name);
140
+ // ⛔ An `update` across a move the diff could not see — refused before any write.
141
+ yield* guardRename(IDENTITY, news, output);
172
142
 
173
143
  const bad = badDurations(form);
174
144
  if (bad.length > 0) {
@@ -195,6 +165,8 @@ export const BaoSshRoleProvider = () =>
195
165
  }
196
166
 
197
167
  const found = yield* readRole(form);
168
+ if (found !== undefined)
169
+ yield* refuseTakeover({ fqn, instanceId, output }, `Bao.SshRole ${path}`);
198
170
  if (found === undefined || !matches(found.attributes, form)) {
199
171
  if (found !== undefined) {
200
172
  const lost = wouldErase(found.live);
@@ -235,5 +207,5 @@ export const BaoSshRoleProvider = () =>
235
207
  return undefined;
236
208
  }),
237
209
  }),
238
- ),
210
+ ).pipe(Effect.map(provingResumes)),
239
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);
@@ -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
+ };