@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,7 +14,8 @@
14
14
  *
15
15
  * ★ REPLACE SEMANTICS (REPLACE.md): `mount` or `name` changed → `replace`, create-first. The new
16
16
  * path cannot collide with the old, so no `deleteFirst`. Under the default `retain` the old role
17
- * stays live, and still admits logins, until removed by hand.
17
+ * stays live, and still admits logins, until removed by hand. ⛔ A move onto a role that already
18
+ * exists fails the plan (rename-identity.ts).
18
19
  * ★ `defaultRemovalPolicy: 'retain'`, like every Bao.* family.
19
20
  */
20
21
  import { Resource } from 'alchemy';
@@ -22,18 +23,20 @@ import { isResolved } from 'alchemy/Diff';
22
23
  import * as Provider from 'alchemy/Provider';
23
24
  import * as Effect from 'effect/Effect';
24
25
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
26
+ import { claimFor } from '../ownership/adopt.ts';
27
+ import { provingResumes } from '../ownership/resume.ts';
25
28
  import { baoDelete } from './bao-http.ts';
26
29
  import {
27
30
  type BaoJwtRoleAttributes,
28
31
  type BaoJwtRoleProps,
29
32
  attributesOf,
30
33
  matches,
31
- mountOf,
32
34
  problems,
33
35
  rolePath,
34
36
  writeBody,
35
37
  } from './jwt-role-form.ts';
36
- import { type RoleSpec, planRole, readRoleAt, reconcileRole } from './role-reconcile.ts';
38
+ import { foldName, guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
39
+ import { type RoleSpec, planRole, readOwnedRole, reconcileRole } from './role-reconcile.ts';
37
40
 
38
41
  export type {
39
42
  BaoJwtCallbackMode,
@@ -54,6 +57,18 @@ export const BaoJwtRole = Resource<BaoJwtRole>('Bao.JwtRole', {
54
57
  defaultRemovalPolicy: 'retain',
55
58
  });
56
59
 
60
+ /**
61
+ * The name folded, as the server keys it (`foldName`): `name` is a TypeLowerCaseString
62
+ * (builtin/credential/jwt/path_role.go:77), so the handler reads it lowercased before it ever builds
63
+ * `role/<name>` (:289). `App` → `app` is the same role, never a move; `problems` refuses the case.
64
+ */
65
+ const IDENTITY = roleIdentity<BaoJwtRoleAttributes>(
66
+ 'Bao.JwtRole',
67
+ (mount, name) => rolePath({ mount, name }),
68
+ 'jwt',
69
+ foldName,
70
+ );
71
+
57
72
  export const jwtRoleSpec = (props: BaoJwtRoleProps): RoleSpec<BaoJwtRoleAttributes> => ({
58
73
  attributesOf: (live) => attributesOf(props, live),
59
74
  body: writeBody(props),
@@ -74,22 +89,26 @@ export const BaoJwtRoleProvider = () =>
74
89
  */
75
90
  list: () => Effect.succeed([]),
76
91
 
77
- read: Effect.fn(function* ({ olds }) {
78
- const found = yield* readRoleAt(jwtRoleSpec(olds));
79
- return found?.attributes;
92
+ /** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
93
+ read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
94
+ return yield* readOwnedRole({ fqn, instanceId, output }, jwtRoleSpec(olds));
80
95
  }),
81
96
 
82
97
  /** ⛔ IT COMPARES THE LIVE ROLE, NOT THE STORED DIGEST — a hand-widened audience is drift. */
83
- diff: Effect.fn(function* ({ news, output }) {
84
- if (output === undefined || !isResolved(news)) return undefined;
85
- if (mountOf(news) !== output.mount || news.name !== output.name) {
86
- return { action: 'replace' } as const;
87
- }
98
+ diff: Effect.fn(function* ({ news, olds, output }) {
99
+ // ⛔ The identity first, before isResolved; onto a role that exists fails the plan.
100
+ const move = yield* judgeRename(IDENTITY, olds, news, output);
101
+ // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
102
+ if (output === undefined) return undefined;
103
+ if (move !== undefined) return { action: 'replace' } as const;
104
+ if (!isResolved(news)) return undefined;
88
105
  return { action: yield* planRole(jwtRoleSpec(news)) } as const;
89
106
  }),
90
107
 
91
- reconcile: Effect.fn(function* ({ news }) {
92
- return yield* reconcileRole(jwtRoleSpec(news));
108
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
109
+ // ⛔ An `update` across a move the diff could not see — refused before any write.
110
+ yield* guardRename(IDENTITY, news, output);
111
+ return yield* reconcileRole(jwtRoleSpec(news), claimFor({ fqn, instanceId, output }));
93
112
  }),
94
113
 
95
114
  /** Idempotent as Alchemy requires — a missing role deletes as success. */
@@ -98,5 +117,5 @@ export const BaoJwtRoleProvider = () =>
98
117
  return undefined;
99
118
  }),
100
119
  }),
101
- ),
120
+ ).pipe(Effect.map(provingResumes)),
102
121
  );
@@ -9,25 +9,28 @@
9
9
  * and nothing else (path_role.go:239-256).
10
10
  *
11
11
  * ★ REPLACE SEMANTICS (REPLACE.md): `mount` or `name` changed → `replace`, create-first (the new
12
- * path cannot collide). Under the default `retain` the old role stays live until removed by hand.
12
+ * path cannot collide with the old). Under the default `retain` the old role stays live until
13
+ * removed by hand. ⛔ A move onto a role that already exists fails the plan (rename-identity.ts).
13
14
  */
14
15
  import { Resource } from 'alchemy';
15
16
  import { isResolved } from 'alchemy/Diff';
16
17
  import * as Provider from 'alchemy/Provider';
17
18
  import * as Effect from 'effect/Effect';
18
19
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
20
+ import { claimFor } from '../ownership/adopt.ts';
21
+ import { provingResumes } from '../ownership/resume.ts';
19
22
  import { baoDelete } from './bao-http.ts';
20
23
  import {
21
24
  type BaoKubernetesRoleAttributes,
22
25
  type BaoKubernetesRoleProps,
23
26
  attributesOf,
24
27
  matches,
25
- mountOf,
26
28
  problems,
27
29
  rolePath,
28
30
  writeBody,
29
31
  } from './kubernetes-role-form.ts';
30
- import { type RoleSpec, planRole, readRoleAt, reconcileRole } from './role-reconcile.ts';
32
+ import { foldName, guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
33
+ import { type RoleSpec, planRole, readOwnedRole, reconcileRole } from './role-reconcile.ts';
31
34
 
32
35
  export type {
33
36
  BaoKubernetesAliasSource,
@@ -47,6 +50,14 @@ export const BaoKubernetesRole = Resource<BaoKubernetesRole>('Bao.KubernetesRole
47
50
  defaultRemovalPolicy: 'retain',
48
51
  });
49
52
 
53
+ /** The name folded, as the server stores it (`foldName`). */
54
+ const IDENTITY = roleIdentity<BaoKubernetesRoleAttributes>(
55
+ 'Bao.KubernetesRole',
56
+ (mount, name) => rolePath({ mount, name }),
57
+ 'kubernetes',
58
+ foldName,
59
+ );
60
+
50
61
  export const kubernetesRoleSpec = (
51
62
  props: BaoKubernetesRoleProps,
52
63
  ): RoleSpec<BaoKubernetesRoleAttributes> => ({
@@ -66,22 +77,29 @@ export const BaoKubernetesRoleProvider = () =>
66
77
  /** ⛔ The mount's role listing is not a list of things this owns. */
67
78
  list: () => Effect.succeed([]),
68
79
 
69
- read: Effect.fn(function* ({ olds }) {
70
- const found = yield* readRoleAt(kubernetesRoleSpec(olds));
71
- return found?.attributes;
80
+ /** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
81
+ read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
82
+ return yield* readOwnedRole({ fqn, instanceId, output }, kubernetesRoleSpec(olds));
72
83
  }),
73
84
 
74
85
  /** ⛔ IT COMPARES THE LIVE ROLE, NOT THE STORED DIGEST. */
75
- diff: Effect.fn(function* ({ news, output }) {
76
- if (output === undefined || !isResolved(news)) return undefined;
77
- if (mountOf(news) !== output.mount || news.name !== output.name) {
78
- return { action: 'replace' } as const;
79
- }
86
+ diff: Effect.fn(function* ({ news, olds, output }) {
87
+ // ⛔ The identity first, before isResolved; onto a role that exists fails the plan.
88
+ const move = yield* judgeRename(IDENTITY, olds, news, output);
89
+ // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
90
+ if (output === undefined) return undefined;
91
+ if (move !== undefined) return { action: 'replace' } as const;
92
+ if (!isResolved(news)) return undefined;
80
93
  return { action: yield* planRole(kubernetesRoleSpec(news)) } as const;
81
94
  }),
82
95
 
83
- reconcile: Effect.fn(function* ({ news }) {
84
- return yield* reconcileRole(kubernetesRoleSpec(news));
96
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
97
+ // ⛔ An `update` across a move the diff could not see — refused before any write.
98
+ yield* guardRename(IDENTITY, news, output);
99
+ return yield* reconcileRole(
100
+ kubernetesRoleSpec(news),
101
+ claimFor({ fqn, instanceId, output }),
102
+ );
85
103
  }),
86
104
 
87
105
  /** Idempotent as Alchemy requires — a missing role deletes as success. */
@@ -90,5 +108,5 @@ export const BaoKubernetesRoleProvider = () =>
90
108
  return undefined;
91
109
  }),
92
110
  }),
93
- ),
111
+ ).pipe(Effect.map(provingResumes)),
94
112
  );
@@ -19,15 +19,21 @@
19
19
  * default `retain` it never runs; with `destroy`, set `retain` to drop it from state and leave it
20
20
  * live — which is what the server would do anyway.
21
21
  *
22
- * ★ REPLACE SEMANTICS (REPLACE.md): `name` changed → `replace`, create-first (names are per
23
- * namespace; the new one cannot collide). Under `retain` BOTH enforcements then apply — fail
24
- * closed. Under `destroy` the old one's delete is refused, so the deploy stops after creating.
22
+ * ★ REPLACE SEMANTICS (REPLACE.md): `name` changed → `replace`, create-first. Under `retain` BOTH
23
+ * enforcements then apply — fail closed. Under `destroy` the old one's delete is refused, so the
24
+ * deploy stops after creating. ⛔ A rename onto an enforcement that exists fails the plan
25
+ * (rename-identity.ts): the upsert by name would otherwise rewrite, in place, what someone else's
26
+ * enforcement demands. MEASURED 2026-09-21: a swap of two under `destroy` wrote each over the
27
+ * other, then stopped on the two refused deletes.
25
28
  */
26
29
  import { Resource } from 'alchemy';
27
30
  import { isResolved } from 'alchemy/Diff';
28
31
  import * as Provider from 'alchemy/Provider';
29
32
  import * as Effect from 'effect/Effect';
30
33
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
34
+ import { type Claim, claimFor } from '../ownership/adopt.ts';
35
+ import { ownedRead } from '../ownership/probe.ts';
36
+ import { provingResumes } from '../ownership/resume.ts';
31
37
  import type { BaoError } from './bao-status.ts';
32
38
  import {
33
39
  type BaoMfaLoginEnforcementAttributes,
@@ -38,7 +44,8 @@ import {
38
44
  problems,
39
45
  writeBody,
40
46
  } from './mfa-enforcement-form.ts';
41
- import { authAccessors, readEnforcement, writeEnforcement } from './mfa-wire.ts';
47
+ import { authAccessors, enforcementPath, readEnforcement, writeEnforcement } from './mfa-wire.ts';
48
+ import { guardRename, judgeRename, nameIdentity } from './rename-identity.ts';
42
49
 
43
50
  export type {
44
51
  BaoMfaLoginEnforcementAttributes,
@@ -59,6 +66,12 @@ export const BaoMfaLoginEnforcement = Resource<BaoMfaLoginEnforcement>('Bao.MfaL
59
66
 
60
67
  type Env = HttpClient.HttpClient;
61
68
 
69
+ /** Exact: the identity store indexes enforcements by (namespace, name) as given. */
70
+ const IDENTITY = nameIdentity<BaoMfaLoginEnforcementAttributes>(
71
+ 'Bao.MfaLoginEnforcement',
72
+ enforcementPath,
73
+ );
74
+
62
75
  const refuse = (name: string, message: string): Effect.Effect<never> =>
63
76
  Effect.die(new Error(`Bao.MfaLoginEnforcement ${name}: ${message}`));
64
77
 
@@ -86,14 +99,21 @@ export const planEnforcement = (
86
99
  return live !== undefined && matches(live, props, resolved) ? 'noop' : 'update';
87
100
  });
88
101
 
89
- /** Write when live differs, then prove it by reading back. */
102
+ /**
103
+ * Write when live differs, then prove it by reading back.
104
+ * ⛔ `claim` (the provider passes it): a create never takes over an enforcement it finds by name.
105
+ */
90
106
  export const reconcileEnforcement = (
91
107
  props: BaoMfaLoginEnforcementProps,
108
+ claim?: Claim,
92
109
  ): Effect.Effect<BaoMfaLoginEnforcementAttributes, BaoError, Env> =>
93
110
  Effect.gen(function* () {
94
111
  const { bad, resolved } = yield* resolveTargets(props);
95
112
  if (bad.length > 0) return yield* refuse(props.name, bad.join('; '));
96
113
  const live = yield* readLive(props.name);
114
+ if (live !== undefined && claim !== undefined) {
115
+ yield* claim(`Bao.MfaLoginEnforcement ${props.name}`);
116
+ }
97
117
  if (live === undefined || !matches(live, props, resolved)) {
98
118
  yield* writeEnforcement(props.name, writeBody(canonicalFromProps(props, resolved)));
99
119
  }
@@ -114,19 +134,28 @@ export const BaoMfaLoginEnforcementProvider = () =>
114
134
  /** ⛔ The namespace's enforcement listing is not a list of things this owns. */
115
135
  list: () => Effect.succeed([]),
116
136
 
117
- read: Effect.fn(function* ({ olds }) {
118
- return yield* readLive(olds.name);
137
+ /** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
138
+ read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
139
+ const found = yield* readLive(olds.name);
140
+ const ours = Effect.map(planEnforcement(olds), (plan) => plan === 'noop');
141
+ return yield* ownedRead({ fqn, instanceId, output }, found, ours);
119
142
  }),
120
143
 
121
144
  /** ⛔ IT COMPARES THE LIVE ENFORCEMENT — a target removed by hand is drift, not a noop. */
122
- diff: Effect.fn(function* ({ news, output }) {
123
- if (output === undefined || !isResolved(news)) return undefined;
124
- if (news.name !== output.name) return { action: 'replace' } as const;
145
+ diff: Effect.fn(function* ({ news, olds, output }) {
146
+ // ⛔ The name first, before isResolved; onto an enforcement that exists fails the plan.
147
+ const move = yield* judgeRename(IDENTITY, olds, news, output);
148
+ // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
149
+ if (output === undefined) return undefined;
150
+ if (move !== undefined) return { action: 'replace' } as const;
151
+ if (!isResolved(news)) return undefined;
125
152
  return { action: yield* planEnforcement(news) } as const;
126
153
  }),
127
154
 
128
- reconcile: Effect.fn(function* ({ news }) {
129
- return yield* reconcileEnforcement(news);
155
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
156
+ // ⛔ An `update` across a rename the diff could not see — refused before any write.
157
+ yield* guardRename(IDENTITY, news, output);
158
+ return yield* reconcileEnforcement(news, claimFor({ fqn, instanceId, output }));
130
159
  }),
131
160
 
132
161
  /** ⛔ REFUSED — read the header: in 2.6.2 the delete does not survive a restart. */
@@ -139,5 +168,5 @@ export const BaoMfaLoginEnforcementProvider = () =>
139
168
  );
140
169
  }),
141
170
  }),
142
- ),
171
+ ).pipe(Effect.map(provingResumes)),
143
172
  );
@@ -20,6 +20,9 @@ import { isResolved } from 'alchemy/Diff';
20
20
  import * as Provider from 'alchemy/Provider';
21
21
  import * as Effect from 'effect/Effect';
22
22
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
23
+ import { type Claim, claimFor } from '../ownership/adopt.ts';
24
+ import { ownedRead } from '../ownership/probe.ts';
25
+ import { provingResumes } from '../ownership/resume.ts';
23
26
  import type { BaoError } from './bao-status.ts';
24
27
  import {
25
28
  type BaoMfaTotpMethodAttributes,
@@ -31,6 +34,7 @@ import {
31
34
  writeBody,
32
35
  } from './mfa-totp-form.ts';
33
36
  import { deleteTotp, findTotp, writeTotp } from './mfa-wire.ts';
37
+ import { declaredString } from './rename.ts';
34
38
 
35
39
  export type {
36
40
  BaoMfaTotpMethodAttributes,
@@ -50,8 +54,8 @@ export const BaoMfaTotpMethod = Resource<BaoMfaTotpMethod>('Bao.MfaTotpMethod',
50
54
  defaultRemovalPolicy: 'retain',
51
55
  });
52
56
 
53
- const refuse = (props: BaoMfaTotpMethodProps, message: string): Effect.Effect<never> =>
54
- Effect.die(new Error(`Bao.MfaTotpMethod ${props.name}: ${message}`));
57
+ const refuse = (name: string, message: string): Effect.Effect<never> =>
58
+ Effect.die(new Error(`Bao.MfaTotpMethod ${name}: ${message}`));
55
59
 
56
60
  export const readTotp = (
57
61
  props: BaoMfaTotpMethodProps,
@@ -69,19 +73,24 @@ export const planTotp = (
69
73
  return live !== undefined && matches(live, props) ? 'noop' : 'update';
70
74
  });
71
75
 
72
- /** Upsert by name when live differs, then prove it by reading back. */
76
+ /**
77
+ * Upsert by name when live differs, then prove it by reading back.
78
+ * ⛔ `claim` (the provider passes it): a create never takes over a method it finds by that name.
79
+ */
73
80
  export const reconcileTotp = (
74
81
  props: BaoMfaTotpMethodProps,
82
+ claim?: Claim,
75
83
  ): Effect.Effect<BaoMfaTotpMethodAttributes, BaoError, HttpClient.HttpClient> =>
76
84
  Effect.gen(function* () {
77
85
  const bad = problems(props);
78
- if (bad.length > 0) return yield* refuse(props, bad.join('; '));
86
+ if (bad.length > 0) return yield* refuse(props.name, bad.join('; '));
79
87
  const live = yield* readTotp(props);
88
+ if (live !== undefined && claim !== undefined) yield* claim(`Bao.MfaTotpMethod ${props.name}`);
80
89
  if (live === undefined || !matches(live, props)) yield* writeTotp(writeBody(props));
81
90
  const after = yield* readTotp(props);
82
91
  if (after === undefined)
83
- return yield* refuse(props, 'the write succeeded but no method has that name.');
84
- if (!matches(after, props)) return yield* refuse(props, 'the method read back different.');
92
+ return yield* refuse(props.name, 'the write succeeded but no method has that name.');
93
+ if (!matches(after, props)) return yield* refuse(props.name, 'the method read back different.');
85
94
  return after;
86
95
  });
87
96
 
@@ -93,19 +102,38 @@ export const BaoMfaTotpMethodProvider = () =>
93
102
  /** ⛔ The namespace's method listing is not a list of things this owns. */
94
103
  list: () => Effect.succeed([]),
95
104
 
96
- read: Effect.fn(function* ({ olds }) {
97
- return yield* readTotp(olds);
105
+ /** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
106
+ read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
107
+ const found = yield* readTotp(olds);
108
+ const ours = Effect.sync(
109
+ () => found !== undefined && problems(olds).length === 0 && matches(found, olds),
110
+ );
111
+ return yield* ownedRead({ fqn, instanceId, output }, found, ours);
98
112
  }),
99
113
 
114
+ /**
115
+ * ⛔ THE NAME IS JUDGED BEFORE `isResolved(news)`. Until 2026-09-21 it came after, so a
116
+ * rename landing with a pending `issuer` (or any other Output) planned `update`, and the
117
+ * upsert by name wrote a SECOND method: the header's stranding, reached through a green
118
+ * plan. MEASURED through the engine (rename-families.test.ts).
119
+ */
100
120
  diff: Effect.fn(function* ({ news, output }) {
101
- if (output === undefined || !isResolved(news)) return undefined;
102
- const rename = renameProblem(output.name, news.name);
103
- if (rename !== undefined) return yield* refuse(news, rename);
121
+ const name = declaredString(news, 'name');
122
+ if (output !== undefined && name !== undefined) {
123
+ const rename = renameProblem(output.name, name);
124
+ if (rename !== undefined) return yield* refuse(name, rename);
125
+ }
126
+ // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
127
+ if (output === undefined) return undefined;
128
+ if (!isResolved(news)) return undefined;
104
129
  return { action: yield* planTotp(news) } as const;
105
130
  }),
106
131
 
107
- reconcile: Effect.fn(function* ({ news }) {
108
- return yield* reconcileTotp(news);
132
+ /** ⛔ A rename the diff could not see (the name was an Output), refused before any write. */
133
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
134
+ const rename = output === undefined ? undefined : renameProblem(output.name, news.name);
135
+ if (rename !== undefined) return yield* refuse(news.name, rename);
136
+ return yield* reconcileTotp(news, claimFor({ fqn, instanceId, output }));
109
137
  }),
110
138
 
111
139
  /** ⛔ Read the header first. Idempotent: an unknown id deletes as success. */
@@ -114,5 +142,5 @@ export const BaoMfaTotpMethodProvider = () =>
114
142
  return undefined;
115
143
  }),
116
144
  }),
117
- ),
145
+ ).pipe(Effect.map(provingResumes)),
118
146
  );
@@ -5,7 +5,8 @@
5
5
  import assert from 'node:assert/strict';
6
6
  import { describe, it } from 'node:test';
7
7
  import { reconcileAuthMethod } from './auth-method-reconcile.ts';
8
- import { type Reply, type Seen, run, withFake } from './fake-bao.ts';
8
+ import { type Seen, run, withFake } from './fake-bao.ts';
9
+ import { liveTable } from './fake-mounts.ts';
9
10
  import { planMove } from './mount-move.ts';
10
11
  import { reconcileMount } from './mount-reconcile.ts';
11
12
 
@@ -32,40 +33,6 @@ describe('planMove', () => {
32
33
  });
33
34
  });
34
35
 
35
- /** A fake with a live table under `prefix` (`sys/mounts` or `sys/auth`) that sys/remount edits. */
36
- const liveTable = (prefix: string, initial: Record<string, string>) => {
37
- const table = new Map(Object.entries(initial).map(([path, type]) => [`${path}/`, type]));
38
- const entry = (type: string) => ({ config: { default_lease_ttl: 0, max_lease_ttl: 0 }, type });
39
- const answer = (seen: Seen): Reply => {
40
- const path = seen.path.replace(/^\/v1\//, '');
41
- if (path === 'sys/remount' && seen.method === 'POST') {
42
- const { from, to } = JSON.parse(seen.body) as { from: string; to: string };
43
- const key = `${from.replace(/^auth\//, '')}/`;
44
- const type = table.get(key);
45
- if (type === undefined) return { json: { errors: ['no matching mount'] }, status: 400 };
46
- table.delete(key);
47
- table.set(`${to.replace(/^auth\//, '')}/`, type);
48
- return { json: { data: { migration_id: 'm-1' } }, status: 200 };
49
- }
50
- if (path.startsWith('sys/remount/status/')) {
51
- return { json: { data: { migration_info: { status: 'success' } } }, status: 200 };
52
- }
53
- if (path === prefix) {
54
- return { json: { data: Object.fromEntries(table) }, status: 200 };
55
- }
56
- const mount = path.slice(prefix.length + 1).replace(/\/tune$/, '');
57
- if (seen.method === 'POST') {
58
- if (!path.endsWith('/tune')) table.set(`${mount}/`, 'enabled-by-test');
59
- return { status: 204 };
60
- }
61
- const type = table.get(`${mount}/`);
62
- return type === undefined
63
- ? { json: { errors: [`No secret engine mount at ${mount}/`] }, status: 400 }
64
- : { json: { data: entry(type) }, status: 200 };
65
- };
66
- return { answer, table };
67
- };
68
-
69
36
  const posts = (seen: Seen[]) =>
70
37
  seen.filter((each) => each.method === 'POST').map((each) => each.path);
71
38
 
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import * as Effect from 'effect/Effect';
6
6
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
7
+ import type { Claim } from '../ownership/adopt.ts';
7
8
  import type { BaoError } from './bao-status.ts';
8
9
  import {
9
10
  type BaoMountAttributes,
@@ -37,22 +38,31 @@ const moveTarget = (props: BaoMountProps): MoveTarget => ({
37
38
  /**
38
39
  * Converge one mount. `stated` is the path in Alchemy's state (undefined on a first reconcile).
39
40
  * Dies on a refusal; fails with OpenBao's own error on a refused call.
41
+ * ⛔ `claim` (mount.ts always passes it) refuses, before any write, a first reconcile that would take
42
+ * over a live mount: the one at the path, or the `remountFrom` source it would move
43
+ * (ownership/adopt.ts).
40
44
  */
41
45
  export const reconcileMount = (
42
46
  news: BaoMountProps,
43
47
  stated: string | undefined,
44
48
  options?: RemountOptions,
49
+ claim?: Claim,
45
50
  ): Effect.Effect<BaoMountAttributes, BaoError | RemountError, HttpClient.HttpClient> =>
46
51
  Effect.gen(function* () {
47
52
  const move = planMove('Bao.Mount', stated, news.path, news.remountFrom);
48
53
  if (move.kind === 'refuse') return yield* Effect.die(new Error(move.message));
54
+ const first = stated === undefined ? claim : undefined;
55
+ if (first !== undefined && move.kind === 'move') {
56
+ const source = yield* readMount({ ...news, path: move.from });
57
+ if (source !== undefined) yield* first(`Bao.Mount ${move.from} (the remountFrom source)`);
58
+ }
49
59
  if (move.kind === 'move') yield* performMove(moveTarget(news), move.from, options);
50
60
 
51
61
  let live = yield* readMount(news);
52
62
  if (live === undefined) {
53
63
  yield* enableMount(news);
54
64
  if (wantsTune(news)) yield* tuneMount(news);
55
- } else if (!matches(live, news)) {
65
+ } else {
56
66
  if (live.type !== news.type) {
57
67
  return yield* Effect.die(
58
68
  new Error(
@@ -61,7 +71,8 @@ export const reconcileMount = (
61
71
  ),
62
72
  );
63
73
  }
64
- yield* tuneMount(news);
74
+ if (first !== undefined) yield* first(`Bao.Mount ${mountPath(news.path)}`);
75
+ if (!matches(live, news)) yield* tuneMount(news);
65
76
  }
66
77
  live = yield* readMount(news);
67
78
  if (live === undefined) {
@@ -22,6 +22,9 @@ import { isResolved } from 'alchemy/Diff';
22
22
  import * as Provider from 'alchemy/Provider';
23
23
  import * as Effect from 'effect/Effect';
24
24
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
25
+ import { claimFor } from '../ownership/adopt.ts';
26
+ import { ownedRead } from '../ownership/probe.ts';
27
+ import { provingResumes } from '../ownership/resume.ts';
25
28
  import { type BaoMountAttributes, type BaoMountProps, matches } from './mount-form.ts';
26
29
  import { planMove } from './mount-move.ts';
27
30
  import { readMount, reconcileMount } from './mount-reconcile.ts';
@@ -55,12 +58,17 @@ export const BaoMountProvider = () =>
55
58
  */
56
59
  list: () => Effect.succeed([]),
57
60
 
58
- read: Effect.fn(function* ({ olds }) {
59
- return yield* readMount(olds);
61
+ /** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
62
+ read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
63
+ const found = yield* readMount(olds);
64
+ const ours = Effect.sync(() => found !== undefined && matches(found, olds));
65
+ return yield* ownedRead({ fqn, instanceId, output }, found, ours);
60
66
  }),
61
67
 
62
68
  diff: Effect.fn(function* ({ news, output }) {
63
- if (output === undefined || !isResolved(news)) return undefined;
69
+ // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
70
+ if (output === undefined) return undefined;
71
+ if (!isResolved(news)) return undefined;
64
72
  /**
65
73
  * ⛔ A CHANGED PATH IS DECIDED HERE, BEFORE ANY READ OF THE NEW PATH — reading it would
66
74
  * find nothing and plan `update`, which is how an empty mount used to get enabled.
@@ -98,8 +106,9 @@ export const BaoMountProvider = () =>
98
106
  * method and path — the job the per-command exit-code checks used to do by hand.
99
107
  * The body, and the move, live in mount-reconcile.ts.
100
108
  */
101
- reconcile: Effect.fn(function* ({ news, output }) {
102
- return yield* reconcileMount(news, output?.path);
109
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
110
+ const claim = claimFor({ fqn, instanceId, output });
111
+ return yield* reconcileMount(news, output?.path, undefined, claim);
103
112
  }),
104
113
 
105
114
  /**
@@ -111,5 +120,5 @@ export const BaoMountProvider = () =>
111
120
  return undefined;
112
121
  }),
113
122
  }),
114
- ),
123
+ ).pipe(Effect.map(provingResumes)),
115
124
  );