@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
@@ -14,6 +14,14 @@ plus `FetchHttpClient.layer`.
14
14
  secret, a CA key, a plugin `env` or an OIDC client secret.
15
15
  - Every family defaults to `retain` on destroy. For what a rename does, read
16
16
  [REPLACE.md](./REPLACE.md) before you change any resource's path or name.
17
+ - ⛔ A rename or move onto a name or path that already exists fails the plan, before anything is
18
+ written (every family but the mounts, whose moves are below). Move through a free name, in two
19
+ deploys, or remove the target first.
20
+ - ⛔ **Nothing is adopted without `--adopt`** (0.9.0), not even an object identical to the
21
+ declaration: a live name with no state fails the plan, or the apply when a prop was an Output.
22
+ Our own interrupted create still resumes when its row can prove it — not one killed while a
23
+ prop was still an Output. [docs/ownership.md](../../docs/ownership.md). Change
24
+ a logical id with Alchemy's `renamedFrom` (REPLACE.md), never by adopting.
17
25
 
18
26
  ## assertBaoIdentity: call it first
19
27
 
@@ -70,9 +78,10 @@ export const hostRoles = Effect.gen(function* () {
70
78
  - A role is named `<class>--<host>`. Class and host names may contain only lowercase letters,
71
79
  digits and single hyphens.
72
80
  - It refuses `secretIdTtl` 0 (a secret_id that never expires), empty policies, `root`, unknown
73
- classes and duplicate hosts.
81
+ classes and a host listed twice in one class. A host in several classes gets one role per class.
74
82
  - ⚠️ Renaming a host or class makes a new role. Under `retain` the old one stays live and keeps
75
- admitting its secret_ids (REPLACE.md).
83
+ admitting its secret_ids (REPLACE.md). Under `destroy` its secret_ids go, but tokens it already
84
+ issued run to their TTL: deleting a role revokes no token.
76
85
 
77
86
  ## BaoJwtRole / BaoKubernetesRole / BaoJwtAuthConfig
78
87
 
@@ -4,9 +4,10 @@ Audited 2026-09-21, against `alchemy@2.0.0-beta.79` and OpenBao v2.6.2 source, i
4
4
  Every kit provider must answer `replace` correctly (vault consolidation plan, "Replace freely").
5
5
 
6
6
  ★ **Measured, not only read.** `fake-stack.ts` runs beta.79's own `Plan.make` and `apply` over an
7
- in-memory state store against the fake engines in `fake-engines.ts`. `policy-rename.test.ts`,
8
- `role-rename.test.ts` and `rename-occupied.test.ts` deploy, rename, and deploy again, then check
9
- the plan and every call that reached the fake.
7
+ in-memory state store against the fake engines in `fake-engines.ts` and `fake-engines-roles.ts`.
8
+ `policy-rename.test.ts`, `role-rename.test.ts`, `rename-occupied.test.ts`,
9
+ `rename-families.test.ts`, `rename-identity.test.ts` and `rename-adoption.test.ts` deploy, rename,
10
+ and deploy again, then check the plan and every call that reached the fake.
10
11
 
11
12
  ## What Alchemy does with each answer
12
13
 
@@ -44,46 +45,60 @@ stayed live. `destroy` could not help, because nothing recorded the old one.
44
45
 
45
46
  ## Answers, by resource
46
47
 
47
- | Resource | Identity change | Answer now | Other changes |
48
- | ------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
49
- | `Bao.Mount` | `path` | ⛔ **fails the plan**, unless `remountFrom` names the old path. Then it is an in-place `update` that moves the mount with `sys/remount`. | `type` → `replace` that **fails at apply** (below). Description and TTLs → `update` (tune). |
50
- | `Bao.AuthMethod` | `path` | Same as `Bao.Mount` (`auth/` prefix). The accessor survives the move. | `type` → `replace` that **fails at apply** (below) |
51
- | `Bao.AuthRole` | `name` | `replace`. Before 2026-09-21 it was `update`, and the old role was orphaned. | `update` |
52
- | `Bao.PkiRole` | `mount`, `name` | `replace`. Before, only a `mount` change was caught; a `name` change orphaned the old role. | `update` (full-replace write) |
53
- | `Bao.JwtRole` | `mount`, `name` | `replace` | `update` (merge write; every managed field is sent) |
54
- | `Bao.KubernetesRole` | `mount`, `name` | `replace` | `update` |
55
- | `Bao.JwtAuthConfig` | `mount` | `replace`. The old delete is a no-op because there is no delete endpoint. | `update` (full-replace write) |
56
- | `Bao.MfaTotpMethod` | `name` | ⛔ **fails the plan**: a new id would strand every enrolled secret | `update` (the upsert keeps the id) |
57
- | `Bao.MfaLoginEnforcement` | `name` | `replace`. Its delete is **refused** (openbao#4030) | `update` |
58
- | `Bao.Policy` | `name` | `replace` (batch 3A). Compared as OpenBao keys it, trimmed and lowercased, so `Admin` → `admin` is the same policy: `noop`. ⛔ **Fails the plan** onto a name that exists (below). | `update` (fragments digest) |
59
- | `Bao.CloudflareRole` | `mount`, `name` | `replace` (batch 3A). Exact; a trailing `/` on the mount is the same path. ⛔ **Fails the plan** onto a role that exists (below). | `update` |
60
- | `Bao.ProxmoxRole` | `mount`, `name` | `replace` (batch 3A). ⛔ **Fails the plan** onto a role that exists, or if the rename also changes `mintUser` unless `allowMintUserChange` names the old one. | `update`. A `mintUser` change at the same path is refused at apply, never replaced. |
48
+ ⛔ Every family below except `Bao.Mount` and `Bao.AuthMethod` judges its identity **before**
49
+ `isResolved(news)`, and **fails the plan** on a move onto an object that already exists
50
+ (`rename-identity.ts`, below). Names compare as the server keys them: exact, except where a row
51
+ says folded.
52
+
53
+ | Resource | Identity change | Answer now | Other changes |
54
+ | ------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
55
+ | `Bao.Mount` | `path` | ⛔ **fails the plan**, unless `remountFrom` names the old path. Then it is an in-place `update` that moves the mount with `sys/remount`. | `type` → `replace` that **fails at apply** (below). Description and TTLs → `update` (tune). |
56
+ | `Bao.AuthMethod` | `path` | Same as `Bao.Mount` (`auth/` prefix). The accessor survives the move. | `type` → `replace` that **fails at apply** (below) |
57
+ | `Bao.AuthRole` | `name` (folded) | `replace`. Before 2026-09-21 it was `update`, and the old role was orphaned. Its delete clears the secret_ids; issued tokens run to their TTL (below). | `update` |
58
+ | `Bao.PkiRole` | `mount`, `name` | `replace`. Before, only a `mount` change was caught; a `name` change orphaned the old role. | `update` (full-replace write) |
59
+ | `Bao.JwtRole` | `mount`, `name` (folded) | `replace`. The name is folded: the server reads it lowercased (TypeLowerCaseString). | `update` (merge write; every managed field is sent) |
60
+ | `Bao.KubernetesRole` | `mount`, `name` (folded) | `replace`. The name is folded, as the server stores it. | `update` |
61
+ | `Bao.JwtAuthConfig` | `mount` | `replace`. The old delete is a no-op because there is no delete endpoint. A move onto a mount with a config fails the plan too: it would rewrite that mount's logins. | `update` (full-replace write) |
62
+ | `Bao.MfaTotpMethod` | `name` | ⛔ **fails the plan**: a new id would strand every enrolled secret. Reconcile refuses it too, when the name was an Output. | `update` (the upsert keeps the id) |
63
+ | `Bao.MfaLoginEnforcement` | `name` | `replace`. Its delete is **refused** (openbao#4030) | `update` |
64
+ | `Bao.SshRole` | `mount`, `name` | `replace` | `update` (full-replace write) |
65
+ | `Bao.Plugin` | `type`, `name`, `version` | `replace`: a version bump is a new catalog entry, and `retain` keeps the old version registered. | `update` (full-replace register) |
66
+ | `Bao.Policy` | `name` | `replace` (batch 3A). Compared as OpenBao keys it, trimmed and lowercased, so `Admin` → `admin` is the same policy: `noop`. | `update` (fragments digest) |
67
+ | `Bao.CloudflareRole` | `mount`, `name` | `replace` (batch 3A). Exact; a trailing `/` on the mount is the same path. | `update` |
68
+ | `Bao.ProxmoxRole` | `mount`, `name` | `replace` (batch 3A). ⛔ **Fails the plan** if the rename also changes `mintUser`, unless `allowMintUserChange` names the old one. | `update`. A `mintUser` change at the same path is refused at apply, never replaced. |
61
69
 
62
70
  `hostAppRoles` makes no resources, but its names are identities: renaming a host or a class
63
71
  renames the role. With the logical id taken from the role name, as the README does, the old id
64
72
  leaves the stack: that is an orphan delete, not a `replace`. With a stable logical id,
65
- `Bao.AuthRole` plans a `replace`. Under the default `retain`, both leave the old role live.
73
+ `Bao.AuthRole` plans a `replace`. Under the default `retain`, both leave the old role live. A
74
+ host may sit in several classes: it gets one role per class, and only the same host twice in one
75
+ class is refused.
66
76
 
67
77
  ## When the new identity is not known at plan
68
78
 
69
79
  - **Only another prop is pending** (say `fragments` is an Output of an upstream that is also
70
- changing). `Bao.Policy`, `Bao.CloudflareRole` and `Bao.ProxmoxRole` judge the identity props
71
- before `isResolved(news)` (`rename.ts`), so the rename still plans `replace`. Measured.
80
+ changing). Every family judges the identity props before `isResolved(news)`
81
+ (`rename-identity.ts`), so the rename still plans `replace`. Measured for all twelve. Before
82
+ 2026-09-21 the nine batch-2 families planned `update` here and orphaned the old object.
72
83
  - **The identity itself is pending.** Then the diff defers: it never answers `replace` on an
73
84
  unknown identity, because under `destroy` a replace onto the same path deletes what it just
74
85
  wrote. The engine plans `update`, and reconcile, seeing the old attributes name another
75
86
  object, **refuses before any write**. The row is left `updating`, with the new props and the
76
- old attributes. The next deploy sees both names and plans `replace`. Measured, for all three.
87
+ old attributes. The next deploy sees both names and plans `replace`. Measured for all twelve.
88
+ `Bao.MfaTotpMethod` refuses there with its rename refusal, and its next plan fails the same way.
77
89
  - `Bao.ProxmoxRole` defers the same way while `mintUser` or `allowMintUserChange` is pending on a
78
90
  rename, because the re-scope guard cannot be checked. Measured for `allowMintUserChange`.
79
91
 
80
92
  ## A move onto an object that already exists fails the plan
81
93
 
82
- ⛔ All three read the new path at plan (`judgeMove`, `rename.ts`) and refuse the move if anything
83
- is there. 🔴 Measured before the guard: two policies that swapped names under
94
+ ⛔ Every family in the table reads the new path at plan (`judgeMove`, `rename.ts`) and refuses the
95
+ move if anything is there. 🔴 Measured before the guard: two policies that swapped names under
84
96
  `RemovalPolicy.destroy()` both planned `replace`. Each new generation wrote over the other's live
85
97
  policy, and each old generation's delete then removed the name the other had just written. The
86
- deploy was green, and both policies were gone, every grant revoked. Measured the same way:
98
+ deploy was green, and both policies were gone, every grant revoked. The same swap deleted both
99
+ objects for `Bao.AuthRole`, `Bao.PkiRole`, `Bao.JwtRole`, `Bao.KubernetesRole`, `Bao.SshRole` and
100
+ `Bao.Plugin`. `Bao.JwtAuthConfig` (no delete) and `Bao.MfaLoginEnforcement` (delete refused) lost
101
+ nothing, but each new generation silently rewrote the other's live object. Measured the same way:
87
102
 
88
103
  - a shift (`a → b` while `b → c`) deleted `b`, and a move onto the name of a resource leaving the
89
104
  stack deleted the name it moved onto;
@@ -94,10 +109,12 @@ deploy was green, and both policies were gone, every grant revoked. Measured the
94
109
 
95
110
  ⚠️ The diff cannot see the removal policy, so this refuses under `retain` too, where a swap would
96
111
  have been harmless. So would a move back onto a retained old generation. Move in two deploys
97
- through a name nothing holds, or remove the target by hand first. A change of policy-name case, or
98
- a trailing `/` on a mount, is the same object, so it is not a move and is never refused.
112
+ through a name nothing holds, or remove the target by hand first. For `Bao.Plugin` that includes a
113
+ version bump onto a version already registered by hand. A change of case the server folds (policy,
114
+ AppRole, JWT and Kubernetes role names), or a trailing `/` on a mount, is the same object, so it is
115
+ not a move and is never refused (`rename-identity.test.ts`).
99
116
 
100
- ## What `retain` means for these three
117
+ ## What `retain` means for batch 3A and AppRole
101
118
 
102
119
  Under the default `retain` the old generation is kept, and the apply says so ("Replaced resource
103
120
  retained."). It is live and unmanaged from then on:
@@ -112,13 +129,29 @@ retained."). It is live and unmanaged from then on:
112
129
  - **`Bao.ProxmoxRole`**: the same, and the old role keeps minting under its old `mint_user`. The
113
130
  plugin's delete leaves outstanding leases revocable (its own
114
131
  `TestDeletingARoleLeavesItsOutstandingLeasesRevocable`).
132
+ - **`Bao.AuthRole`**: the old role keeps admitting its secret_ids. Under `destroy` the delete
133
+ removes every secret_id and the role_id, so no new login succeeds, but it revokes no token:
134
+ tokens already issued live to their TTL and only fail to renew (openbao v2.6.2 approle
135
+ `pathRoleDelete`, path_role.go:1858-1902; `pathLoginRenew`, path_login.go:414-438). Revoke by
136
+ accessor to contain one.
115
137
 
116
138
  ★ A stack that puts `mount` and `name` in the logical id, as homeflare-openbao's
117
139
  `declareCloudflareRoles` does, never reaches the `replace`: the old id leaves the stack as an
118
140
  orphan delete, which `retain` also keeps live.
119
141
 
120
- ⚠️ A NEW declaration (a new logical id) of a name that already exists still takes that object over
121
- and rewrites it: these families' `read` never answers `Unowned`, so Alchemy adopts it.
142
+ ## A new declaration of a live name: refused unless adopted
143
+
144
+ ⛔ A NEW logical id whose name already exists now FAILS THE PLAN (0.9.0): with no state, `read`
145
+ answers `Unowned` for any live object, identical or not (`src/ownership/probe.ts`,
146
+ [docs/ownership.md](../../docs/ownership.md)). Nothing moved, so `judgeMove` never runs; the
147
+ probe is what catches it. 🔴 Measured before (`rename-adoption.test.ts`): the families' `read`
148
+ answered plain attributes, so Alchemy silently adopted the object, and under `destroy` the old
149
+ owner's delete then ran after the adoption wrote and removed the object the new id claimed, in a
150
+ green deploy — for each of the nine batch-2 families whose delete runs (a TOTP method and its
151
+ enrolments included), and when a new `Bao.AuthRole` took a name another one moved off in the same
152
+ deploy. ⚠️ `--adopt` is exactly that takeover again, on purpose. Change a logical id with Alchemy's
153
+ `renamedFrom('<old id>')`, which migrates the state row and plans an `update`. Take a freed name
154
+ in the deploy after the move.
122
155
 
123
156
  ## Why the moves fail instead of replacing
124
157
 
@@ -152,12 +185,9 @@ take every secret with it. (Corrected 2026-09-21: the first audit listed this as
152
185
  3. **Unique names need `deleteFirst`.** Only a mount's `type` change keeps its unique path, and
153
186
  it fails on purpose instead (above).
154
187
 
155
- ## Open
188
+ ## Closed
156
189
 
157
- - ⚠️ **Nine families still check the identity after `isResolved(news)`**: `Bao.AuthRole`,
158
- `Bao.PkiRole`, `Bao.JwtRole`, `Bao.KubernetesRole`, `Bao.JwtAuthConfig`, `Bao.MfaTotpMethod`,
159
- `Bao.MfaLoginEnforcement`, `Bao.SshRole` and `Bao.Plugin`. A rename that lands in the same
160
- deploy as any pending Output plans `update`, and none of their reconciles has the moved-update
161
- guard. For `Bao.MfaTotpMethod` that writes a second method and strands every enrolment. The
162
- helpers in `rename.ts` close it the same way. (`Bao.Mount` and `Bao.AuthMethod` are safe: their
163
- reconcile re-plans the move from the stated path.)
190
+ - ✅ **Nine families checked the identity after `isResolved(news)`** and never read the target
191
+ (closed 2026-09-21 by `rename-identity.ts`, measured in `rename-families.test.ts`).
192
+ `Bao.Mount` and `Bao.AuthMethod` never needed it: their reconcile re-plans the move from the
193
+ stated path.
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The ownership rule for the five families fake-core.ts carries — Bao.Policy, Bao.CloudflareRole,
3
+ * Bao.ProxmoxRole, Bao.Mount and Bao.AuthMethod — through Alchemy's own plan and apply: the same
4
+ * claims adopt-families.test.ts and adopt-recovery.test.ts make for the other nine.
5
+ *
6
+ * ⛔ TEST-ONLY. Every value is a placeholder, not the estate's.
7
+ */
8
+ import assert from 'node:assert/strict';
9
+ import { describe, it } from 'node:test';
10
+ import * as Effect from 'effect/Effect';
11
+ import { BaoAuthRole } from './auth-role.ts';
12
+ import { crashAfterWrite } from './fake-bao.ts';
13
+ import { CORE, type CoreRow, withCore } from './fake-core.ts';
14
+ import { writesOf } from './fake-stack.ts';
15
+ import { BaoMount } from './mount.ts';
16
+
17
+ const NOT_OWNED = /Cannot adopt resource 'Z'.*not owned by this stack.*--adopt/s;
18
+ const TAKEOVER = /: already exists, and this stack holds no state for it.*Nothing was written/s;
19
+
20
+ /** `Z`, with its knob an Output until apply: an upstream AppRole's `tokenTtl`, created with it. */
21
+ const pending = (row: CoreRow) =>
22
+ Effect.gen(function* () {
23
+ const up = yield* BaoAuthRole('Upstream', {
24
+ name: 'upstream',
25
+ secretIdTtl: '24h',
26
+ tokenMaxTtl: '1h',
27
+ tokenPolicies: ['default'],
28
+ tokenTtl: '15m',
29
+ });
30
+ yield* row.declare('Z', up.tokenTtl);
31
+ });
32
+
33
+ for (const row of CORE) {
34
+ describe(`${row.family}: ownership`, () => {
35
+ it('a live object another stack owns, identical, fails the plan; nothing is written', async () => {
36
+ await withCore(async (stack, estate, seen, owner) => {
37
+ await owner.deploy(row.declare('X', '15m'));
38
+ seen.length = 0;
39
+ await assert.rejects(stack.deploy(row.declare('Z', '15m')), NOT_OWNED);
40
+ assert.deepEqual(writesOf(seen), []);
41
+ assert.ok(row.has(estate));
42
+ assert.deepEqual(await stack.deploy(row.declare('Z', '15m'), { adopt: true }), {
43
+ Z: 'adopted',
44
+ });
45
+ assert.deepEqual(writesOf(seen), []);
46
+ });
47
+ });
48
+
49
+ it('with a prop still an Output, the apply refuses it; the next plan refuses too', async () => {
50
+ await withCore(async (stack, _estate, seen, owner) => {
51
+ await owner.deploy(row.declare('X', '15m'));
52
+ seen.length = 0;
53
+ await assert.rejects(stack.deploy(pending(row)), TAKEOVER);
54
+ // ★ The refused create forgot its `creating` row, so the next plan probes: still refused.
55
+ await assert.rejects(stack.deploy(pending(row)), NOT_OWNED);
56
+ assert.deepEqual(
57
+ writesOf(seen).filter((line) => !line.endsWith('/upstream')),
58
+ [],
59
+ );
60
+ });
61
+ });
62
+
63
+ it('with a prop still an Output, --adopt takes it over at apply', async () => {
64
+ await withCore(async (stack, _estate, _seen, owner) => {
65
+ await owner.deploy(row.declare('X', '15m'));
66
+ assert.deepEqual(await stack.deploy(pending(row), { adopt: true }), {
67
+ Upstream: 'create',
68
+ Z: 'create',
69
+ });
70
+ });
71
+ });
72
+
73
+ it('an interrupted create is resumed without --adopt, and nothing is rewritten', async () => {
74
+ const fault = crashAfterWrite();
75
+ await withCore(async (stack, estate, seen) => {
76
+ // ★ Bao.Policy reads nothing back after its write, so the crash is the write's own reply.
77
+ fault.crashNext(row.family === 'Bao.Policy' ? 'write' : 'read-back');
78
+ await assert.rejects(stack.deploy(row.declare('X', '15m')), /injected/);
79
+ assert.ok(row.has(estate));
80
+ seen.length = 0;
81
+ assert.deepEqual(await stack.deploy(row.declare('X', '15m')), { X: 'create' });
82
+ assert.deepEqual(writesOf(seen), []);
83
+ }, fault.wrap);
84
+ });
85
+ });
86
+ }
87
+
88
+ describe('Bao.Mount: remountFrom on a create', () => {
89
+ const moved = BaoMount('Kv', { path: 'kv-moved', remountFrom: 'kv', type: 'kv' });
90
+ const SOURCE = /Bao\.Mount kv \(the remountFrom source\): already exists/;
91
+
92
+ it('moving a live mount this stack holds no state for is refused before the move', async () => {
93
+ await withCore(async (stack, estate, seen, owner) => {
94
+ await owner.deploy(BaoMount('Src', { path: 'kv', type: 'kv' }));
95
+ seen.length = 0;
96
+ // ★ The probe reads only the target, which is free: the plan says create.
97
+ await assert.rejects(stack.deploy(moved), SOURCE);
98
+ assert.deepEqual(writesOf(seen), []);
99
+ assert.deepEqual([...estate.mounts.table.keys()], ['kv/']);
100
+ assert.deepEqual(await stack.deploy(moved, { adopt: true }), { Kv: 'create' });
101
+ assert.deepEqual([...estate.mounts.table.keys()], ['kv-moved/']);
102
+ });
103
+ });
104
+ });
105
+
106
+ describe('Bao.Mount: a create interrupted between its enable and its tune', () => {
107
+ it('is not proven ours — the mount is not what was declared — so it resumes with --adopt', async () => {
108
+ const fault = crashAfterWrite();
109
+ const kv = BaoMount('Kv', { defaultLeaseTtl: '15m', path: 'kv', type: 'kv' });
110
+ await withCore(async (stack, estate) => {
111
+ fault.crashNext('write');
112
+ await assert.rejects(stack.deploy(kv), /injected/);
113
+ assert.ok(estate.mounts.table.has('kv/'));
114
+ await assert.rejects(stack.deploy(kv), /Cannot resume creating resource 'Kv'.*--adopt/s);
115
+ assert.deepEqual(await stack.deploy(kv, { adopt: true }), { Kv: 'create' });
116
+ assert.deepEqual(await stack.deploy(kv), { Kv: 'noop' });
117
+ }, fault.wrap);
118
+ });
119
+ });
@@ -0,0 +1,97 @@
1
+ /**
2
+ * NOTHING IS ADOPTED WITHOUT `--adopt` (decided 2026-09-21), for the nine batch-2 families, through
3
+ * Alchemy's own plan and apply (fake-stack.ts). The object is put there by ANOTHER stack over the
4
+ * same fake (`owner`) — the case the rule exists for: identical to this declaration, and still
5
+ * not ours.
6
+ * · plan time: the adoption probe answers `Unowned` (ownership/probe.ts);
7
+ * · apply time: where the probe never ran — a prop still an Output — reconcile refuses before any
8
+ * write (ownership/adopt.ts).
9
+ * adopt-recovery.test.ts pins the other side: our own interrupted create is still resumed.
10
+ *
11
+ * ⛔ TEST-ONLY. Every value is a placeholder, not the estate's.
12
+ */
13
+ import assert from 'node:assert/strict';
14
+ import { describe, it } from 'node:test';
15
+ import { adopt } from 'alchemy/AdoptPolicy';
16
+ import * as Effect from 'effect/Effect';
17
+ import type { Seen } from './fake-bao.ts';
18
+ import { FAMILIES, type Family, upstream, withEstate } from './fake-families.ts';
19
+ import { writesOf } from './fake-stack.ts';
20
+
21
+ const NOT_OWNED = /Cannot adopt resource 'Z'.*not owned by this stack.*--adopt/s;
22
+ const TAKEOVER = /: already exists, and this stack holds no state for it.*Nothing was written/s;
23
+ const STILL_REFUSED = /Cannot adopt resource 'Z'.*--adopt/s;
24
+
25
+ /** `Z` names `a`, with its knob an Output until apply: the upstream AppRole is created with it. */
26
+ const pending = (row: Family, scoped?: boolean) =>
27
+ Effect.gen(function* () {
28
+ const up = yield* upstream('15m');
29
+ const declared = row.declare('Z', 'a', up.tokenTtl);
30
+ yield* scoped === undefined ? declared : declared.pipe(adopt(scoped));
31
+ });
32
+
33
+ /** Writes other than the upstream AppRole's own create. */
34
+ const writesToA = (seen: readonly Seen[]) =>
35
+ writesOf(seen).filter((line) => !line.endsWith('/upstream'));
36
+
37
+ for (const row of FAMILIES) {
38
+ describe(`${row.family}: a live object another stack owns`, () => {
39
+ it('identical to the declaration, still fails the plan; nothing is written', async () => {
40
+ await withEstate(async (stack, estate, seen, owner) => {
41
+ await owner.deploy(row.declare('X', 'a', '15m'));
42
+ seen.length = 0;
43
+ await assert.rejects(stack.deploy(row.declare('Z', 'a', '15m')), NOT_OWNED);
44
+ assert.deepEqual(writesOf(seen), []);
45
+ assert.ok(row.has(estate, 'a'));
46
+ });
47
+ });
48
+
49
+ it('is adopted with --adopt, and with adopt(true) — identical, so nothing is written', async () => {
50
+ for (const deploy of ['flag', 'scope'] as const) {
51
+ await withEstate(async (stack, _estate, seen, owner) => {
52
+ await owner.deploy(row.declare('X', 'a', '15m'));
53
+ seen.length = 0;
54
+ const body =
55
+ deploy === 'flag'
56
+ ? stack.deploy(row.declare('Z', 'a', '15m'), { adopt: true })
57
+ : stack.deploy(row.declare('Z', 'a', '15m').pipe(adopt(true)));
58
+ assert.deepEqual(await body, { Z: 'adopted' });
59
+ assert.deepEqual(writesOf(seen), []);
60
+ });
61
+ }
62
+ });
63
+
64
+ it('with a prop still an Output, the plan cannot ask, so the apply refuses', async () => {
65
+ await withEstate(async (stack, estate, seen, owner) => {
66
+ await owner.deploy(row.declare('X', 'a', '15m'));
67
+ seen.length = 0;
68
+ await assert.rejects(stack.deploy(pending(row)), TAKEOVER);
69
+ assert.deepEqual(writesToA(seen), []);
70
+ assert.ok(row.has(estate, 'a'));
71
+ // ★ The refused create forgot its `creating` row, so the next plan probes: still refused.
72
+ await assert.rejects(stack.deploy(pending(row)), STILL_REFUSED);
73
+ assert.deepEqual(writesToA(seen), []);
74
+ });
75
+ });
76
+
77
+ it('with a prop still an Output, --adopt and adopt(true) take it over at apply', async () => {
78
+ for (const deploy of ['flag', 'scope'] as const) {
79
+ await withEstate(async (stack, _estate, _seen, owner) => {
80
+ await owner.deploy(row.declare('X', 'a', '15m'));
81
+ const planned =
82
+ deploy === 'flag'
83
+ ? await stack.deploy(pending(row), { adopt: true })
84
+ : await stack.deploy(pending(row, true));
85
+ assert.deepEqual(planned, { Upstream: 'create', Z: 'create' });
86
+ });
87
+ }
88
+ });
89
+
90
+ it('adopt(false) on the resource wins over --adopt, at apply as at plan', async () => {
91
+ await withEstate(async (stack, _estate, _seen, owner) => {
92
+ await owner.deploy(row.declare('X', 'a', '15m'));
93
+ await assert.rejects(stack.deploy(pending(row, false), { adopt: true }), TAKEOVER);
94
+ });
95
+ });
96
+ });
97
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * A deploy killed BEFORE a create's claim ran leaves a `creating` (or `replacing`) row that proves
3
+ * nothing: its create never asked whose object sat at its identity. Found by the red team on
4
+ * 2026-09-21, through Alchemy's own plan and apply, before ownership/whole.ts and the proof in
5
+ * ownership/resume.ts:
6
+ * · an Output IDENTITY — every role family read an empty name, found nothing, "resumed" the create
7
+ * and wrote over another owner's object;
8
+ * · an Output KNOB — Bao.Mount and Bao.AuthMethod read the hole as "not managed", adopted another
9
+ * owner's mount in the recovery read and tuned it;
10
+ * · a REPLACE killed before its write — the next deploy wrote over whatever another owner had put
11
+ * at the new identity since.
12
+ * Each is now refused, writing nothing; `--adopt` resumes it, as Alchemy offers for a create.
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 * as RemovalPolicy from 'alchemy/RemovalPolicy';
19
+ import * as Effect from 'effect/Effect';
20
+ import { BaoAuthRole } from './auth-role.ts';
21
+ import { type Seen, refuseWriteOnce } from './fake-bao.ts';
22
+ import { CORE, type CoreRow, withCore } from './fake-core.ts';
23
+ import { FAMILIES, type Family, upstream, withEstate } from './fake-families.ts';
24
+ import { writesOf } from './fake-stack.ts';
25
+ import { BaoMount } from './mount.ts';
26
+ import { BaoPkiRole } from './pki-role.ts';
27
+
28
+ const REFUSED =
29
+ /Cannot resume creating resource 'Z'|: already exists, and this stack holds no state/s;
30
+ const RESUME_REFUSED = /already exists, and this stack holds no state.*Deploy with --adopt/s;
31
+
32
+ /** Writes other than the upstream's. */
33
+ const writesBut = (seen: readonly Seen[], upstreamPath: string) =>
34
+ writesOf(seen).filter((line) => !line.endsWith(upstreamPath));
35
+
36
+ /**
37
+ * `Z`'s NAME is an Output: an upstream named `a` hands it over. An AppRole, or — for Bao.AuthRole,
38
+ * whose own `a` that would be — a PKI role.
39
+ */
40
+ const namedByUpstream = (row: Family) => {
41
+ const approle = row.family !== 'Bao.AuthRole';
42
+ const path = approle ? '/auth/approle/role/a' : '/pki/roles/a';
43
+ const body = Effect.gen(function* () {
44
+ const up = approle
45
+ ? yield* BaoAuthRole('Upstream', {
46
+ name: 'a',
47
+ secretIdTtl: '24h',
48
+ tokenMaxTtl: '1h',
49
+ tokenPolicies: ['default'],
50
+ tokenTtl: '15m',
51
+ })
52
+ : yield* BaoPkiRole('Upstream', {
53
+ allowedDomains: ['x.test'],
54
+ maxTtl: '1h',
55
+ name: 'a',
56
+ ttl: '30m',
57
+ });
58
+ yield* row.declare('Z', up.name, '15m');
59
+ });
60
+ return { body, path };
61
+ };
62
+
63
+ for (const row of FAMILIES) {
64
+ describe(`${row.family}: a create killed before its claim, its name an Output`, () => {
65
+ it('never writes over the object another stack owns; --adopt takes it over', async () => {
66
+ const { body, path } = namedByUpstream(row);
67
+ const fault = refuseWriteOnce(path);
68
+ await withEstate(async (stack, _estate, seen, owner) => {
69
+ await owner.deploy(row.declare('X', 'a', '30m'));
70
+ await assert.rejects(stack.deploy(body), /injected/);
71
+ seen.length = 0;
72
+ await assert.rejects(stack.deploy(body), REFUSED);
73
+ assert.deepEqual(writesBut(seen, path), []);
74
+ await stack.deploy(body, { adopt: true });
75
+ }, fault);
76
+ });
77
+ });
78
+
79
+ describe(`${row.family}: a create killed before its claim, a knob an Output`, () => {
80
+ it('never adopts the object another stack owns', async () => {
81
+ const fault = refuseWriteOnce('/upstream');
82
+ const body = Effect.gen(function* () {
83
+ yield* row.declare('Z', 'a', (yield* upstream('15m')).tokenTtl);
84
+ });
85
+ await withEstate(async (stack, _estate, seen, owner) => {
86
+ await owner.deploy(row.declare('X', 'a', '30m'));
87
+ await assert.rejects(stack.deploy(body), /injected/);
88
+ seen.length = 0;
89
+ await assert.rejects(stack.deploy(body), REFUSED);
90
+ assert.deepEqual(writesBut(seen, '/upstream'), []);
91
+ }, fault);
92
+ });
93
+ });
94
+ }
95
+
96
+ /** `Z` at the row's fixed identity, its knob an upstream AppRole's `tokenTtl`. */
97
+ const pendingCore = (row: CoreRow) =>
98
+ Effect.gen(function* () {
99
+ const up = yield* BaoAuthRole('Upstream', {
100
+ name: 'upstream',
101
+ secretIdTtl: '24h',
102
+ tokenMaxTtl: '1h',
103
+ tokenPolicies: ['default'],
104
+ tokenTtl: '15m',
105
+ });
106
+ yield* row.declare('Z', up.tokenTtl);
107
+ });
108
+
109
+ for (const row of CORE) {
110
+ describe(`${row.family}: a create killed before its claim, a knob an Output`, () => {
111
+ it('never adopts — or tunes — the object another stack owns; --adopt takes it over', async () => {
112
+ const fault = refuseWriteOnce('/upstream');
113
+ await withCore(async (stack, _estate, seen, owner) => {
114
+ await owner.deploy(row.declare('X', '30m'));
115
+ await assert.rejects(stack.deploy(pendingCore(row)), /injected/);
116
+ seen.length = 0;
117
+ await assert.rejects(stack.deploy(pendingCore(row)), REFUSED);
118
+ assert.deepEqual(writesBut(seen, '/upstream'), []);
119
+ await stack.deploy(pendingCore(row), { adopt: true });
120
+ }, fault);
121
+ });
122
+ });
123
+ }
124
+
125
+ describe('Bao.Mount: a create killed before its claim, its path an Output', () => {
126
+ it('never tunes the mount another stack owns; --adopt takes it over', async () => {
127
+ const fault = refuseWriteOnce('/auth/approle/role/kv');
128
+ const body = Effect.gen(function* () {
129
+ const up = yield* BaoAuthRole('Upstream', {
130
+ name: 'kv',
131
+ secretIdTtl: '24h',
132
+ tokenMaxTtl: '1h',
133
+ tokenPolicies: ['default'],
134
+ tokenTtl: '15m',
135
+ });
136
+ yield* BaoMount('Z', { defaultLeaseTtl: '15m', path: up.name, type: 'kv' });
137
+ });
138
+ await withCore(async (stack, _estate, seen, owner) => {
139
+ await owner.deploy(BaoMount('X', { defaultLeaseTtl: '30m', path: 'kv', type: 'kv' }));
140
+ await assert.rejects(stack.deploy(body), /injected/);
141
+ seen.length = 0;
142
+ await assert.rejects(stack.deploy(body), REFUSED);
143
+ assert.deepEqual(writesBut(seen, '/auth/approle/role/kv'), []);
144
+ await stack.deploy(body, { adopt: true });
145
+ }, fault);
146
+ });
147
+ });
148
+
149
+ /** Bao.MfaTotpMethod never renames (mfa-totp.ts), so it has no replace to interrupt. */
150
+ for (const row of FAMILIES.filter((each) => each.family !== 'Bao.MfaTotpMethod')) {
151
+ describe(`${row.family}: a replace killed before its write`, () => {
152
+ it('never writes over what another stack put at the new identity since; --adopt does', async () => {
153
+ const suffix = row.family === 'Bao.JwtAuthConfig' ? '/b/config' : '/b';
154
+ const fault = refuseWriteOnce(suffix);
155
+ const { retain } = RemovalPolicy;
156
+ await withEstate(async (stack, _estate, seen, owner) => {
157
+ await stack.deploy(row.declare('X', 'a', '15m', retain));
158
+ await assert.rejects(stack.deploy(row.declare('X', 'b', '15m', retain)), /injected/);
159
+ await owner.deploy(row.declare('O', 'b', '30m'));
160
+ seen.length = 0;
161
+ await assert.rejects(stack.deploy(row.declare('X', 'b', '15m', retain)), RESUME_REFUSED);
162
+ assert.deepEqual(writesOf(seen), []);
163
+ await stack.deploy(row.declare('X', 'b', '15m', retain), { adopt: true });
164
+ assert.notDeepEqual(writesOf(seen), []);
165
+ }, fault);
166
+ });
167
+ });
168
+ }