@homeflare/alchemy 0.8.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 (240) hide show
  1. package/README.md +22 -11
  2. package/dist/caddy/config-lifecycle.d.ts +1 -1
  3. package/dist/caddy/config.d.ts.map +1 -1
  4. package/dist/caddy/index.js +221 -53
  5. package/dist/caddy/index.js.map +13 -9
  6. package/dist/launchd/fake-runner.d.ts +1 -0
  7. package/dist/launchd/fake-runner.d.ts.map +1 -1
  8. package/dist/launchd/fake-sudo.d.ts +1 -0
  9. package/dist/launchd/fake-sudo.d.ts.map +1 -1
  10. package/dist/launchd/host-effect.d.ts +9 -18
  11. package/dist/launchd/host-effect.d.ts.map +1 -1
  12. package/dist/launchd/host-file-lifecycle.d.ts +14 -1
  13. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  14. package/dist/launchd/host-file.d.ts +2 -1
  15. package/dist/launchd/host-file.d.ts.map +1 -1
  16. package/dist/launchd/index.js +345 -86
  17. package/dist/launchd/index.js.map +18 -12
  18. package/dist/launchd/job-lifecycle.d.ts +8 -1
  19. package/dist/launchd/job-lifecycle.d.ts.map +1 -1
  20. package/dist/launchd/job-preflight.d.ts +13 -1
  21. package/dist/launchd/job-preflight.d.ts.map +1 -1
  22. package/dist/launchd/job.d.ts +2 -1
  23. package/dist/launchd/job.d.ts.map +1 -1
  24. package/dist/launchd/runner.d.ts +7 -0
  25. package/dist/launchd/runner.d.ts.map +1 -1
  26. package/dist/launchd/sudo-acl.d.ts +25 -0
  27. package/dist/launchd/sudo-acl.d.ts.map +1 -0
  28. package/dist/launchd/sudo-guard.d.ts +31 -4
  29. package/dist/launchd/sudo-guard.d.ts.map +1 -1
  30. package/dist/launchd/sudo-runner.d.ts +3 -3
  31. package/dist/launchd/sudo-runner.d.ts.map +1 -1
  32. package/dist/launchd/sudo-said.d.ts +10 -0
  33. package/dist/launchd/sudo-said.d.ts.map +1 -0
  34. package/dist/openbao/auth-method-reconcile.d.ts +6 -2
  35. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -1
  36. package/dist/openbao/auth-method.d.ts.map +1 -1
  37. package/dist/openbao/auth-role.d.ts.map +1 -1
  38. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  39. package/dist/openbao/fake-bao.d.ts +18 -0
  40. package/dist/openbao/fake-bao.d.ts.map +1 -1
  41. package/dist/openbao/fake-core.d.ts +39 -0
  42. package/dist/openbao/fake-core.d.ts.map +1 -0
  43. package/dist/openbao/fake-families.d.ts +6 -3
  44. package/dist/openbao/fake-families.d.ts.map +1 -1
  45. package/dist/openbao/fake-mounts.d.ts +16 -0
  46. package/dist/openbao/fake-mounts.d.ts.map +1 -0
  47. package/dist/openbao/fake-stack.d.ts +5 -1
  48. package/dist/openbao/fake-stack.d.ts.map +1 -1
  49. package/dist/openbao/index.js +541 -270
  50. package/dist/openbao/index.js.map +26 -21
  51. package/dist/openbao/jwt-config.d.ts.map +1 -1
  52. package/dist/openbao/jwt-role.d.ts.map +1 -1
  53. package/dist/openbao/kubernetes-role.d.ts.map +1 -1
  54. package/dist/openbao/mfa-enforcement.d.ts +6 -2
  55. package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
  56. package/dist/openbao/mfa-totp.d.ts +6 -2
  57. package/dist/openbao/mfa-totp.d.ts.map +1 -1
  58. package/dist/openbao/mount-reconcile.d.ts +5 -1
  59. package/dist/openbao/mount-reconcile.d.ts.map +1 -1
  60. package/dist/openbao/mount.d.ts.map +1 -1
  61. package/dist/openbao/pki-role.d.ts.map +1 -1
  62. package/dist/openbao/plugin-reconcile.d.ts +6 -2
  63. package/dist/openbao/plugin-reconcile.d.ts.map +1 -1
  64. package/dist/openbao/plugin.d.ts.map +1 -1
  65. package/dist/openbao/policy.d.ts.map +1 -1
  66. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  67. package/dist/openbao/role-reconcile.d.ts +11 -1
  68. package/dist/openbao/role-reconcile.d.ts.map +1 -1
  69. package/dist/openbao/ssh-role.d.ts.map +1 -1
  70. package/dist/ownership/adopt.d.ts +52 -0
  71. package/dist/ownership/adopt.d.ts.map +1 -0
  72. package/dist/ownership/probe.d.ts +13 -0
  73. package/dist/ownership/probe.d.ts.map +1 -0
  74. package/dist/ownership/resume.d.ts +40 -0
  75. package/dist/ownership/resume.d.ts.map +1 -0
  76. package/dist/ownership/rows.d.ts +33 -0
  77. package/dist/ownership/rows.d.ts.map +1 -0
  78. package/dist/ownership/whole.d.ts +14 -0
  79. package/dist/ownership/whole.d.ts.map +1 -0
  80. package/dist/proxmox/ceph-pool-form.d.ts +3 -3
  81. package/dist/proxmox/ceph-pool.d.ts.map +1 -1
  82. package/dist/proxmox/fake-bao-env.d.ts +3 -0
  83. package/dist/proxmox/fake-bao-env.d.ts.map +1 -0
  84. package/dist/proxmox/fake-pve-lxc.d.ts +42 -0
  85. package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -0
  86. package/dist/proxmox/fake-pve.d.ts +38 -0
  87. package/dist/proxmox/fake-pve.d.ts.map +1 -0
  88. package/dist/proxmox/index.d.ts +6 -2
  89. package/dist/proxmox/index.d.ts.map +1 -1
  90. package/dist/proxmox/index.js +970 -186
  91. package/dist/proxmox/index.js.map +22 -9
  92. package/dist/proxmox/lxc-create-form.d.ts +6 -0
  93. package/dist/proxmox/lxc-create-form.d.ts.map +1 -0
  94. package/dist/proxmox/lxc-harness.d.ts +34 -0
  95. package/dist/proxmox/lxc-harness.d.ts.map +1 -0
  96. package/dist/proxmox/lxc-identity.d.ts +18 -0
  97. package/dist/proxmox/lxc-identity.d.ts.map +1 -0
  98. package/dist/proxmox/lxc-judge.d.ts +46 -0
  99. package/dist/proxmox/lxc-judge.d.ts.map +1 -0
  100. package/dist/proxmox/lxc-lifecycle.d.ts +68 -0
  101. package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -0
  102. package/dist/proxmox/lxc-props.d.ts +139 -0
  103. package/dist/proxmox/lxc-props.d.ts.map +1 -0
  104. package/dist/proxmox/lxc-volume.d.ts +29 -0
  105. package/dist/proxmox/lxc-volume.d.ts.map +1 -0
  106. package/dist/proxmox/lxc-wire.d.ts +51 -0
  107. package/dist/proxmox/lxc-wire.d.ts.map +1 -0
  108. package/dist/proxmox/lxc.d.ts +27 -47
  109. package/dist/proxmox/lxc.d.ts.map +1 -1
  110. package/dist/proxmox/network-apply-read.d.ts +1 -1
  111. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  112. package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
  113. package/dist/proxmox/resource.d.ts.map +1 -1
  114. package/dist/proxmox/update-guard.d.ts +27 -0
  115. package/dist/proxmox/update-guard.d.ts.map +1 -0
  116. package/dist/verify/args.d.ts +16 -0
  117. package/dist/verify/args.d.ts.map +1 -0
  118. package/dist/verify/cli.d.ts +3 -0
  119. package/dist/verify/cli.d.ts.map +1 -0
  120. package/dist/verify/cli.js +320 -0
  121. package/dist/verify/cli.js.map +15 -0
  122. package/dist/verify/fake-engine.d.ts +57 -0
  123. package/dist/verify/fake-engine.d.ts.map +1 -0
  124. package/dist/verify/index.d.ts +16 -0
  125. package/dist/verify/index.d.ts.map +1 -0
  126. package/dist/verify/index.js +297 -0
  127. package/dist/verify/index.js.map +14 -0
  128. package/dist/verify/report.d.ts +6 -0
  129. package/dist/verify/report.d.ts.map +1 -0
  130. package/dist/verify/rows.d.ts +57 -0
  131. package/dist/verify/rows.d.ts.map +1 -0
  132. package/dist/verify/spy.d.ts +31 -0
  133. package/dist/verify/spy.d.ts.map +1 -0
  134. package/dist/verify/verify.d.ts +43 -0
  135. package/dist/verify/verify.d.ts.map +1 -0
  136. package/docs/adopt-verify.md +108 -0
  137. package/docs/adopted-deploys.md +85 -0
  138. package/docs/credentials.md +15 -0
  139. package/docs/launchd-sudo.md +31 -6
  140. package/docs/launchd.md +7 -3
  141. package/docs/ownership.md +113 -0
  142. package/docs/proxmox-lxc.md +196 -0
  143. package/docs/proxmox.md +12 -0
  144. package/package.json +9 -2
  145. package/src/caddy/adopt-scope.test.ts +1 -1
  146. package/src/caddy/config-lifecycle.ts +1 -1
  147. package/src/caddy/config.ts +2 -1
  148. package/src/launchd/adopt-apply.test.ts +166 -0
  149. package/src/launchd/fake-runner.ts +26 -4
  150. package/src/launchd/host-effect.ts +1 -33
  151. package/src/launchd/host-file-lifecycle.ts +39 -14
  152. package/src/launchd/host-file.ts +13 -4
  153. package/src/launchd/job-lifecycle.ts +22 -14
  154. package/src/launchd/job-preflight.ts +18 -1
  155. package/src/launchd/job.ts +13 -4
  156. package/src/launchd/runner.ts +7 -0
  157. package/src/launchd/sudo-acl.ts +68 -0
  158. package/src/launchd/sudo-chain.test.ts +121 -0
  159. package/src/launchd/sudo-guard.ts +89 -10
  160. package/src/launchd/sudo-modes.test.ts +135 -0
  161. package/src/launchd/sudo-runner.ts +40 -59
  162. package/src/launchd/sudo-said.ts +58 -0
  163. package/src/openbao/README.md +5 -2
  164. package/src/openbao/REPLACE.md +13 -11
  165. package/src/openbao/adopt-core.test.ts +119 -0
  166. package/src/openbao/adopt-families.test.ts +97 -0
  167. package/src/openbao/adopt-holes.test.ts +168 -0
  168. package/src/openbao/adopt-recovery.test.ts +71 -0
  169. package/src/openbao/auth-method-reconcile.ts +15 -3
  170. package/src/openbao/auth-method.ts +15 -6
  171. package/src/openbao/auth-role.ts +14 -4
  172. package/src/openbao/cloudflare-role.ts +17 -4
  173. package/src/openbao/fake-bao.ts +52 -0
  174. package/src/openbao/fake-core.ts +141 -0
  175. package/src/openbao/fake-families.ts +11 -5
  176. package/src/openbao/fake-mounts.ts +74 -0
  177. package/src/openbao/fake-stack.ts +17 -4
  178. package/src/openbao/jwt-config.ts +10 -7
  179. package/src/openbao/jwt-role.ts +10 -7
  180. package/src/openbao/kubernetes-role.ts +13 -7
  181. package/src/openbao/mfa-enforcement.ts +20 -6
  182. package/src/openbao/mfa-totp.ts +22 -7
  183. package/src/openbao/mount-move.test.ts +2 -35
  184. package/src/openbao/mount-reconcile.ts +13 -2
  185. package/src/openbao/mount.ts +15 -6
  186. package/src/openbao/pki-role.ts +15 -4
  187. package/src/openbao/plugin-reconcile.ts +16 -5
  188. package/src/openbao/plugin.ts +17 -6
  189. package/src/openbao/policy.ts +17 -4
  190. package/src/openbao/proxmox-role.ts +13 -4
  191. package/src/openbao/rename-adoption.test.ts +32 -19
  192. package/src/openbao/role-reconcile.ts +23 -0
  193. package/src/openbao/ssh-role.ts +14 -4
  194. package/src/ownership/adopt.ts +122 -0
  195. package/src/ownership/ownership.test.ts +200 -0
  196. package/src/ownership/probe.ts +65 -0
  197. package/src/ownership/resume.ts +118 -0
  198. package/src/ownership/rows.ts +117 -0
  199. package/src/ownership/whole.test.ts +39 -0
  200. package/src/ownership/whole.ts +60 -0
  201. package/src/proxmox/adopt-noop.test.ts +112 -0
  202. package/src/proxmox/ceph-pool-adopt.test.ts +88 -0
  203. package/src/proxmox/ceph-pool-form.ts +3 -3
  204. package/src/proxmox/ceph-pool.ts +10 -3
  205. package/src/proxmox/fake-bao-env.ts +28 -0
  206. package/src/proxmox/fake-pve-lxc.ts +210 -0
  207. package/src/proxmox/fake-pve.ts +75 -0
  208. package/src/proxmox/index.test.ts +6 -6
  209. package/src/proxmox/index.ts +6 -2
  210. package/src/proxmox/lxc-adopt.test.ts +170 -0
  211. package/src/proxmox/lxc-create-form.ts +76 -0
  212. package/src/proxmox/lxc-create.test.ts +126 -0
  213. package/src/proxmox/lxc-guards.test.ts +158 -0
  214. package/src/proxmox/lxc-harness.ts +150 -0
  215. package/src/proxmox/lxc-identity.ts +43 -0
  216. package/src/proxmox/lxc-judge.test.ts +175 -0
  217. package/src/proxmox/lxc-judge.ts +196 -0
  218. package/src/proxmox/lxc-lifecycle.ts +200 -0
  219. package/src/proxmox/lxc-ownership.test.ts +159 -0
  220. package/src/proxmox/lxc-props.ts +215 -0
  221. package/src/proxmox/lxc-read.test.ts +60 -0
  222. package/src/proxmox/lxc-volume.test.ts +96 -0
  223. package/src/proxmox/lxc-volume.ts +153 -0
  224. package/src/proxmox/lxc-wire.ts +210 -0
  225. package/src/proxmox/lxc.ts +216 -93
  226. package/src/proxmox/network-apply-read.ts +3 -1
  227. package/src/proxmox/pbs-datastore.ts +4 -3
  228. package/src/proxmox/resource.ts +7 -7
  229. package/src/proxmox/update-guard.ts +31 -0
  230. package/src/verify/args.ts +82 -0
  231. package/src/verify/cli.test.ts +86 -0
  232. package/src/verify/cli.ts +49 -0
  233. package/src/verify/fake-engine.ts +158 -0
  234. package/src/verify/index.ts +36 -0
  235. package/src/verify/report.ts +52 -0
  236. package/src/verify/rows.ts +169 -0
  237. package/src/verify/spy.test.ts +141 -0
  238. package/src/verify/spy.ts +149 -0
  239. package/src/verify/verify.test.ts +124 -0
  240. package/src/verify/verify.ts +172 -0
@@ -17,6 +17,8 @@ import { isResolved } from 'alchemy/Diff';
17
17
  import * as Provider from 'alchemy/Provider';
18
18
  import * as Effect from 'effect/Effect';
19
19
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
20
+ import { claimFor } from '../ownership/adopt.ts';
21
+ import { provingResumes } from '../ownership/resume.ts';
20
22
  import { baoDelete } from './bao-http.ts';
21
23
  import {
22
24
  type BaoKubernetesRoleAttributes,
@@ -28,7 +30,7 @@ import {
28
30
  writeBody,
29
31
  } from './kubernetes-role-form.ts';
30
32
  import { foldName, guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
31
- import { type RoleSpec, planRole, readRoleAt, reconcileRole } from './role-reconcile.ts';
33
+ import { type RoleSpec, planRole, readOwnedRole, reconcileRole } from './role-reconcile.ts';
32
34
 
33
35
  export type {
34
36
  BaoKubernetesAliasSource,
@@ -75,25 +77,29 @@ export const BaoKubernetesRoleProvider = () =>
75
77
  /** ⛔ The mount's role listing is not a list of things this owns. */
76
78
  list: () => Effect.succeed([]),
77
79
 
78
- read: Effect.fn(function* ({ olds }) {
79
- const found = yield* readRoleAt(kubernetesRoleSpec(olds));
80
- 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));
81
83
  }),
82
84
 
83
85
  /** ⛔ IT COMPARES THE LIVE ROLE, NOT THE STORED DIGEST. */
84
86
  diff: Effect.fn(function* ({ news, olds, output }) {
85
87
  // ⛔ The identity first, before isResolved; onto a role that exists fails the plan.
86
88
  const move = yield* judgeRename(IDENTITY, olds, news, output);
89
+ // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
87
90
  if (output === undefined) return undefined;
88
91
  if (move !== undefined) return { action: 'replace' } as const;
89
92
  if (!isResolved(news)) return undefined;
90
93
  return { action: yield* planRole(kubernetesRoleSpec(news)) } as const;
91
94
  }),
92
95
 
93
- reconcile: Effect.fn(function* ({ news, output }) {
96
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
94
97
  // ⛔ An `update` across a move the diff could not see — refused before any write.
95
98
  yield* guardRename(IDENTITY, news, output);
96
- return yield* reconcileRole(kubernetesRoleSpec(news));
99
+ return yield* reconcileRole(
100
+ kubernetesRoleSpec(news),
101
+ claimFor({ fqn, instanceId, output }),
102
+ );
97
103
  }),
98
104
 
99
105
  /** Idempotent as Alchemy requires — a missing role deletes as success. */
@@ -102,5 +108,5 @@ export const BaoKubernetesRoleProvider = () =>
102
108
  return undefined;
103
109
  }),
104
110
  }),
105
- ),
111
+ ).pipe(Effect.map(provingResumes)),
106
112
  );
@@ -31,6 +31,9 @@ import { isResolved } from 'alchemy/Diff';
31
31
  import * as Provider from 'alchemy/Provider';
32
32
  import * as Effect from 'effect/Effect';
33
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';
34
37
  import type { BaoError } from './bao-status.ts';
35
38
  import {
36
39
  type BaoMfaLoginEnforcementAttributes,
@@ -96,14 +99,21 @@ export const planEnforcement = (
96
99
  return live !== undefined && matches(live, props, resolved) ? 'noop' : 'update';
97
100
  });
98
101
 
99
- /** 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
+ */
100
106
  export const reconcileEnforcement = (
101
107
  props: BaoMfaLoginEnforcementProps,
108
+ claim?: Claim,
102
109
  ): Effect.Effect<BaoMfaLoginEnforcementAttributes, BaoError, Env> =>
103
110
  Effect.gen(function* () {
104
111
  const { bad, resolved } = yield* resolveTargets(props);
105
112
  if (bad.length > 0) return yield* refuse(props.name, bad.join('; '));
106
113
  const live = yield* readLive(props.name);
114
+ if (live !== undefined && claim !== undefined) {
115
+ yield* claim(`Bao.MfaLoginEnforcement ${props.name}`);
116
+ }
107
117
  if (live === undefined || !matches(live, props, resolved)) {
108
118
  yield* writeEnforcement(props.name, writeBody(canonicalFromProps(props, resolved)));
109
119
  }
@@ -124,24 +134,28 @@ export const BaoMfaLoginEnforcementProvider = () =>
124
134
  /** ⛔ The namespace's enforcement listing is not a list of things this owns. */
125
135
  list: () => Effect.succeed([]),
126
136
 
127
- read: Effect.fn(function* ({ olds }) {
128
- 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);
129
142
  }),
130
143
 
131
144
  /** ⛔ IT COMPARES THE LIVE ENFORCEMENT — a target removed by hand is drift, not a noop. */
132
145
  diff: Effect.fn(function* ({ news, olds, output }) {
133
146
  // ⛔ The name first, before isResolved; onto an enforcement that exists fails the plan.
134
147
  const move = yield* judgeRename(IDENTITY, olds, news, output);
148
+ // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
135
149
  if (output === undefined) return undefined;
136
150
  if (move !== undefined) return { action: 'replace' } as const;
137
151
  if (!isResolved(news)) return undefined;
138
152
  return { action: yield* planEnforcement(news) } as const;
139
153
  }),
140
154
 
141
- reconcile: Effect.fn(function* ({ news, output }) {
155
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
142
156
  // ⛔ An `update` across a rename the diff could not see — refused before any write.
143
157
  yield* guardRename(IDENTITY, news, output);
144
- return yield* reconcileEnforcement(news);
158
+ return yield* reconcileEnforcement(news, claimFor({ fqn, instanceId, output }));
145
159
  }),
146
160
 
147
161
  /** ⛔ REFUSED — read the header: in 2.6.2 the delete does not survive a restart. */
@@ -154,5 +168,5 @@ export const BaoMfaLoginEnforcementProvider = () =>
154
168
  );
155
169
  }),
156
170
  }),
157
- ),
171
+ ).pipe(Effect.map(provingResumes)),
158
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,
@@ -70,14 +73,19 @@ export const planTotp = (
70
73
  return live !== undefined && matches(live, props) ? 'noop' : 'update';
71
74
  });
72
75
 
73
- /** 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
+ */
74
80
  export const reconcileTotp = (
75
81
  props: BaoMfaTotpMethodProps,
82
+ claim?: Claim,
76
83
  ): Effect.Effect<BaoMfaTotpMethodAttributes, BaoError, HttpClient.HttpClient> =>
77
84
  Effect.gen(function* () {
78
85
  const bad = problems(props);
79
86
  if (bad.length > 0) return yield* refuse(props.name, bad.join('; '));
80
87
  const live = yield* readTotp(props);
88
+ if (live !== undefined && claim !== undefined) yield* claim(`Bao.MfaTotpMethod ${props.name}`);
81
89
  if (live === undefined || !matches(live, props)) yield* writeTotp(writeBody(props));
82
90
  const after = yield* readTotp(props);
83
91
  if (after === undefined)
@@ -94,8 +102,13 @@ export const BaoMfaTotpMethodProvider = () =>
94
102
  /** ⛔ The namespace's method listing is not a list of things this owns. */
95
103
  list: () => Effect.succeed([]),
96
104
 
97
- read: Effect.fn(function* ({ olds }) {
98
- 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);
99
112
  }),
100
113
 
101
114
  /**
@@ -110,15 +123,17 @@ export const BaoMfaTotpMethodProvider = () =>
110
123
  const rename = renameProblem(output.name, name);
111
124
  if (rename !== undefined) return yield* refuse(name, rename);
112
125
  }
113
- if (output === undefined || !isResolved(news)) return undefined;
126
+ // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
127
+ if (output === undefined) return undefined;
128
+ if (!isResolved(news)) return undefined;
114
129
  return { action: yield* planTotp(news) } as const;
115
130
  }),
116
131
 
117
132
  /** ⛔ A rename the diff could not see (the name was an Output), refused before any write. */
118
- reconcile: Effect.fn(function* ({ news, output }) {
133
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
119
134
  const rename = output === undefined ? undefined : renameProblem(output.name, news.name);
120
135
  if (rename !== undefined) return yield* refuse(news.name, rename);
121
- return yield* reconcileTotp(news);
136
+ return yield* reconcileTotp(news, claimFor({ fqn, instanceId, output }));
122
137
  }),
123
138
 
124
139
  /** ⛔ Read the header first. Idempotent: an unknown id deletes as success. */
@@ -127,5 +142,5 @@ export const BaoMfaTotpMethodProvider = () =>
127
142
  return undefined;
128
143
  }),
129
144
  }),
130
- ),
145
+ ).pipe(Effect.map(provingResumes)),
131
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
  );
@@ -43,6 +43,9 @@ import { isResolved } from 'alchemy/Diff';
43
43
  import * as Provider from 'alchemy/Provider';
44
44
  import * as Effect from 'effect/Effect';
45
45
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
46
+ import { refuseTakeover } from '../ownership/adopt.ts';
47
+ import { ownedRead } from '../ownership/probe.ts';
48
+ import { provingResumes } from '../ownership/resume.ts';
46
49
  import { baoDelete, baoRead, baoWrite } from './bao-http.ts';
47
50
  import {
48
51
  type BaoPkiRoleAttributes,
@@ -95,8 +98,13 @@ export const BaoPkiRoleProvider = () =>
95
98
  */
96
99
  list: () => Effect.succeed([]),
97
100
 
98
- read: Effect.fn(function* ({ olds }) {
99
- return yield* readRole(olds);
101
+ /** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
102
+ read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
103
+ const found = yield* readRole(olds);
104
+ const ours = Effect.sync(
105
+ () => found !== undefined && problems(olds).length === 0 && matches(found, olds),
106
+ );
107
+ return yield* ownedRead({ fqn, instanceId, output }, found, ours);
100
108
  }),
101
109
 
102
110
  /**
@@ -119,6 +127,7 @@ export const BaoPkiRoleProvider = () =>
119
127
  * and left the old one issuing certificates under no state record at all.
120
128
  */
121
129
  const move = yield* judgeRename(IDENTITY, olds, news, output);
130
+ // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
122
131
  if (output === undefined) return undefined;
123
132
  if (move !== undefined) return { action: 'replace' } as const;
124
133
  if (!isResolved(news)) return undefined;
@@ -137,7 +146,7 @@ export const BaoPkiRoleProvider = () =>
137
146
  : ({ action: 'update' } as const);
138
147
  }),
139
148
 
140
- reconcile: Effect.fn(function* ({ news, output }) {
149
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
141
150
  const path = rolePath(news);
142
151
  // ⛔ An `update` across a move the diff could not see — refused before any write.
143
152
  yield* guardRename(IDENTITY, news, output);
@@ -153,6 +162,8 @@ export const BaoPkiRoleProvider = () =>
153
162
  return yield* Effect.die(new Error(`Bao.PkiRole ${path}: ${bad.join(' ')}`));
154
163
  }
155
164
  const live = yield* readRole(news);
165
+ if (live !== undefined)
166
+ yield* refuseTakeover({ fqn, instanceId, output }, `Bao.PkiRole ${path}`);
156
167
  if (live === undefined || !matches(live, news)) {
157
168
  yield* baoWrite('PUT', path, writeBody(news));
158
169
  }
@@ -195,5 +206,5 @@ export const BaoPkiRoleProvider = () =>
195
206
  return undefined;
196
207
  }),
197
208
  }),
198
- ),
209
+ ).pipe(Effect.map(provingResumes)),
199
210
  );
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import * as Effect from 'effect/Effect';
6
6
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
7
+ import type { Claim } from '../ownership/adopt.ts';
7
8
  import type { BaoError } from './bao-status.ts';
8
9
  import {
9
10
  type BaoPluginAttributes,
@@ -39,18 +40,25 @@ const refuse = (form: BaoPluginForm, message: string) =>
39
40
  * nothing, or finds an OLDER genuinely-unversioned entry. The Cloudflare engine reports one
40
41
  * (`framework.Backend.RunningVersion`). REASONED FROM SOURCE, not measured against a server.
41
42
  * ⚠️ THE WRITE HAS ALREADY LANDED when this fires, so the catalog holds a registration no state
42
- * record names. Declaring the reported version adopts it on the next deploy (same sha256 and
43
- * command, so reconcile matches and writes nothing); declaring a DIFFERENT version is refused by
44
- * the server itself (setInternal :1219-1221, "plugin version mismatch").
43
+ * record names. Declaring the reported version and deploying once with `--adopt` adopts it (same
44
+ * sha256 and command, so reconcile matches and writes nothing); declaring a DIFFERENT version is
45
+ * refused by the server itself (setInternal :1219-1221, "plugin version mismatch").
46
+ * ⚠️ `--adopt`, NOT A SILENT RESUME (2026-09-21): the row this create left names the UNVERSIONED
47
+ * entry, so it cannot prove the versioned one ours (docs/ownership.md).
45
48
  */
46
49
  const SELF_REPORTED =
47
50
  'the write succeeded but the read-back does not match. If the binary reports its own version, ' +
48
51
  'OpenBao filed the registration under it (plugin_catalog.go setInternal) — declare `version` ' +
49
- 'as exactly the version the binary reports; that adopts the entry this write just made.';
52
+ 'as exactly the version the binary reports, and deploy once with --adopt: that adopts the ' +
53
+ 'entry this write just made.';
50
54
 
51
- /** Register when the live entry differs, then prove it by reading back. Dies on a refusal. */
55
+ /**
56
+ * Register when the live entry differs, then prove it by reading back. Dies on a refusal.
57
+ * ⛔ `claim` (the provider passes it): a create never takes over an entry already registered.
58
+ */
52
59
  export const reconcilePlugin = (
53
60
  props: BaoPluginProps,
61
+ claim?: Claim,
54
62
  ): Effect.Effect<BaoPluginAttributes, BaoError, HttpClient.HttpClient> =>
55
63
  Effect.gen(function* () {
56
64
  const form = resolve(props);
@@ -58,6 +66,9 @@ export const reconcilePlugin = (
58
66
  if (bad.length > 0) return yield* refuse(form, bad.join('; '));
59
67
 
60
68
  const found = yield* readEntry(form);
69
+ if (found !== undefined && claim !== undefined) {
70
+ yield* claim(`Bao.Plugin ${versionedPath(form.type, form.name, form.version)}`);
71
+ }
61
72
  if (found !== undefined && !matches(found.attributes, form)) {
62
73
  /**
63
74
  * ⛔ A DECLARATIVE OR OCI ENTRY BELONGS TO THE SERVER'S CONFIG FILE (`plugin` stanzas with
@@ -24,11 +24,15 @@ import { isResolved } from 'alchemy/Diff';
24
24
  import * as Provider from 'alchemy/Provider';
25
25
  import * as Effect from 'effect/Effect';
26
26
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
27
+ import { claimFor } from '../ownership/adopt.ts';
28
+ import { ownedRead } from '../ownership/probe.ts';
29
+ import { provingResumes } from '../ownership/resume.ts';
27
30
  import {
28
31
  type BaoPluginAttributes,
29
32
  type BaoPluginProps,
30
33
  type BaoPluginType,
31
34
  matches,
35
+ problems,
32
36
  resolve,
33
37
  versionedPath,
34
38
  } from './plugin-form.ts';
@@ -81,9 +85,15 @@ export const BaoPluginProvider = () =>
81
85
  */
82
86
  list: () => Effect.succeed([]),
83
87
 
84
- read: Effect.fn(function* ({ olds }) {
85
- const found = yield* readEntry(resolve(olds));
86
- return found?.attributes;
88
+ /** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
89
+ read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
90
+ const form = resolve(olds);
91
+ const found = yield* readEntry(form);
92
+ const ours = Effect.sync(
93
+ () =>
94
+ found !== undefined && problems(form).length === 0 && matches(found.attributes, form),
95
+ );
96
+ return yield* ownedRead({ fqn, instanceId, output }, found?.attributes, ours);
87
97
  }),
88
98
 
89
99
  /** ⛔ IT COMPARES THE LIVE ENTRY, NOT THE STORED DIGEST — a hand re-register is drift. */
@@ -95,6 +105,7 @@ export const BaoPluginProvider = () =>
95
105
  * that is already registered fails the plan (rename-identity.ts).
96
106
  */
97
107
  const move = yield* judgeRename(IDENTITY, olds, news, output);
108
+ // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
98
109
  if (output === undefined) return undefined;
99
110
  if (move !== undefined) return { action: 'replace' } as const;
100
111
  if (!isResolved(news)) return undefined;
@@ -107,10 +118,10 @@ export const BaoPluginProvider = () =>
107
118
  }),
108
119
 
109
120
  /** The refusals — declarative, builtin, self-reported version — live in plugin-reconcile.ts. */
110
- reconcile: Effect.fn(function* ({ news, output }) {
121
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
111
122
  // ⛔ An `update` across a move the diff could not see — refused before any write.
112
123
  yield* guardRename(IDENTITY, news, output);
113
- return yield* reconcilePlugin(news);
124
+ return yield* reconcilePlugin(news, claimFor({ fqn, instanceId, output }));
114
125
  }),
115
126
 
116
127
  /**
@@ -122,5 +133,5 @@ export const BaoPluginProvider = () =>
122
133
  return undefined;
123
134
  }),
124
135
  }),
125
- ),
136
+ ).pipe(Effect.map(provingResumes)),
126
137
  );
@@ -5,6 +5,9 @@ import * as Effect from 'effect/Effect';
5
5
  import * as FileSystem from 'effect/FileSystem';
6
6
  import * as Path from 'effect/Path';
7
7
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
8
+ import { refuseTakeover } from '../ownership/adopt.ts';
9
+ import { ownedRead } from '../ownership/probe.ts';
10
+ import { provingResumes } from '../ownership/resume.ts';
8
11
  import { sha256 } from './digest.ts';
9
12
  import { isEmptyAssembly } from './policy-assembly.ts';
10
13
  import { deletePolicy, policyPath, readPolicy, writePolicy } from './policy-wire.ts';
@@ -138,9 +141,16 @@ export const BaoPolicyProvider = () =>
138
141
  list: () => Effect.succeed([]),
139
142
 
140
143
  // ★ '' means genuinely absent (a 404); a refusal fails. policy-wire.ts has the history.
141
- read: Effect.fn(function* ({ olds }) {
144
+ // ⛔ With no attributes in state, a live policy is `Unowned` unless it is the one our own
145
+ // interrupted create wrote: these fragments, byte for byte (ownership/probe.ts).
146
+ read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
142
147
  const live = yield* readPolicy(olds.name);
143
- return live.length === 0 ? undefined : attributesOf(olds.name, live, 0);
148
+ const found = live.length === 0 ? undefined : attributesOf(olds.name, live, 0);
149
+ const ours = Effect.map(
150
+ assemble(olds.fragments),
151
+ (a) => sha256(a.joined) === found?.digest,
152
+ );
153
+ return yield* ownedRead({ fqn, instanceId, output }, found, ours);
144
154
  }),
145
155
 
146
156
  /**
@@ -161,6 +171,7 @@ export const BaoPolicyProvider = () =>
161
171
  * PR (REPLACE.md).
162
172
  */
163
173
  const move = yield* judgeRename(IDENTITY, olds, news, output);
174
+ // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
164
175
  if (output === undefined) return undefined;
165
176
  if (move !== undefined) return { action: 'replace' } as const;
166
177
  // ⚠️ A prop can still be an unresolved Output or Config at plan time. Docker's
@@ -174,7 +185,7 @@ export const BaoPolicyProvider = () =>
174
185
  : ({ action: 'update' } as const);
175
186
  }),
176
187
 
177
- reconcile: Effect.fn(function* ({ news, output }) {
188
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
178
189
  const name = news.name;
179
190
  // ⛔ An `update` across a rename the diff could not see — refused before any write.
180
191
  yield* guardRename(IDENTITY, news, output);
@@ -207,6 +218,8 @@ export const BaoPolicyProvider = () =>
207
218
  * sibling PVE provider does exactly this in pveOperations.reconcile.
208
219
  */
209
220
  const current = yield* readPolicy(name);
221
+ if (current.length > 0)
222
+ yield* refuseTakeover({ fqn, instanceId, output }, `Bao.Policy ${name}`);
210
223
  if (current.length > 0 && sha256(current) === sha256(joined)) {
211
224
  return attributesOf(name, joined, parts);
212
225
  }
@@ -231,5 +244,5 @@ export const BaoPolicyProvider = () =>
231
244
  return undefined;
232
245
  }),
233
246
  });
234
- }),
247
+ }).pipe(Effect.map(provingResumes)),
235
248
  );
@@ -40,6 +40,9 @@ import { isResolved } from 'alchemy/Diff';
40
40
  import * as Provider from 'alchemy/Provider';
41
41
  import * as Effect from 'effect/Effect';
42
42
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
43
+ import { refuseTakeover } from '../ownership/adopt.ts';
44
+ import { ownedRead } from '../ownership/probe.ts';
45
+ import { provingResumes } from '../ownership/resume.ts';
43
46
  import { baoDelete, baoWrite } from './bao-http.ts';
44
47
  import {
45
48
  type BaoProxmoxRoleAttributes,
@@ -84,8 +87,11 @@ export const BaoProxmoxRoleProvider = () =>
84
87
  */
85
88
  list: () => Effect.succeed([]),
86
89
 
87
- read: Effect.fn(function* ({ olds }) {
88
- return yield* readRole(olds);
90
+ /** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
91
+ read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
92
+ const found = yield* readRole(olds);
93
+ const ours = Effect.sync(() => found !== undefined && matches(found, olds));
94
+ return yield* ownedRead({ fqn, instanceId, output }, found, ours);
89
95
  }),
90
96
 
91
97
  /**
@@ -107,6 +113,7 @@ export const BaoProxmoxRoleProvider = () =>
107
113
  * resulting `update`.
108
114
  */
109
115
  const move = yield* judgeRename(IDENTITY, olds, news, output);
116
+ // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
110
117
  if (output === undefined) return undefined;
111
118
  if (move !== undefined) {
112
119
  const mintUser = declaredString(news, 'mintUser');
@@ -142,11 +149,13 @@ export const BaoProxmoxRoleProvider = () =>
142
149
  return { action: 'update' } as const;
143
150
  }),
144
151
 
145
- reconcile: Effect.fn(function* ({ news, output }) {
152
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
146
153
  const path = rolePath(news.mount, news.name);
147
154
  // ⛔ An `update` across a move the diff could not see — refused before any read or write.
148
155
  yield* guardRename(IDENTITY, news, output);
149
156
  const live = yield* readRole(news);
157
+ if (live !== undefined)
158
+ yield* refuseTakeover({ fqn, instanceId, output }, `Bao.ProxmoxRole ${path}`);
150
159
  /**
151
160
  * ⛔ A mint_user CHANGE RE-SCOPES EVERY CREDENTIAL THE ROLE WILL EVER MINT, SILENTLY.
152
161
  * The minted token inherits that PVE user's standing ACL and nothing else, so
@@ -226,5 +235,5 @@ export const BaoProxmoxRoleProvider = () =>
226
235
  return undefined;
227
236
  }),
228
237
  }),
229
- ),
238
+ ).pipe(Effect.map(provingResumes)),
230
239
  );