@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
@@ -19,6 +19,9 @@ import { isResolved } from 'alchemy/Diff';
19
19
  import * as Provider from 'alchemy/Provider';
20
20
  import * as Effect from 'effect/Effect';
21
21
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
22
+ import { claimFor } from '../ownership/adopt.ts';
23
+ import { ownedRead } from '../ownership/probe.ts';
24
+ import { provingResumes } from '../ownership/resume.ts';
22
25
  import {
23
26
  type BaoAuthMethodAttributes,
24
27
  type BaoAuthMethodProps,
@@ -54,12 +57,17 @@ export const BaoAuthMethodProvider = () =>
54
57
  */
55
58
  list: () => Effect.succeed([]),
56
59
 
57
- read: Effect.fn(function* ({ olds }) {
58
- return yield* readMethod(olds);
60
+ /** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
61
+ read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
62
+ const found = yield* readMethod(olds);
63
+ const ours = Effect.sync(() => found !== undefined && matches(found, olds));
64
+ return yield* ownedRead({ fqn, instanceId, output }, found, ours);
59
65
  }),
60
66
 
61
67
  diff: Effect.fn(function* ({ news, output }) {
62
- if (output === undefined || !isResolved(news)) return undefined;
68
+ // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
69
+ if (output === undefined) return undefined;
70
+ if (!isResolved(news)) return undefined;
63
71
  /**
64
72
  * ⛔ A CHANGED PATH FAILS HERE unless `remountFrom` names the old one — the same trap
65
73
  * Bao.Mount had: reading the new path finds nothing, plans `update`, and enables an
@@ -81,8 +89,9 @@ export const BaoAuthMethodProvider = () =>
81
89
  }),
82
90
 
83
91
  /** The body, and the move, live in auth-method-reconcile.ts. */
84
- reconcile: Effect.fn(function* ({ news, output }) {
85
- return yield* reconcileAuthMethod(news, output?.path);
92
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
93
+ const claim = claimFor({ fqn, instanceId, output });
94
+ return yield* reconcileAuthMethod(news, output?.path, undefined, claim);
86
95
  }),
87
96
 
88
97
  /**
@@ -94,5 +103,5 @@ export const BaoAuthMethodProvider = () =>
94
103
  return undefined;
95
104
  }),
96
105
  }),
97
- ),
106
+ ).pipe(Effect.map(provingResumes)),
98
107
  );
@@ -14,6 +14,9 @@ import { isResolved } from 'alchemy/Diff';
14
14
  import * as Provider from 'alchemy/Provider';
15
15
  import * as Effect from 'effect/Effect';
16
16
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
17
+ import { refuseTakeover } from '../ownership/adopt.ts';
18
+ import { ownedRead } from '../ownership/probe.ts';
19
+ import { provingResumes } from '../ownership/resume.ts';
17
20
  import {
18
21
  type BaoAuthRoleAttributes,
19
22
  type BaoAuthRoleProps,
@@ -66,8 +69,11 @@ export const BaoAuthRoleProvider = () =>
66
69
  */
67
70
  list: () => Effect.succeed([]),
68
71
 
69
- read: Effect.fn(function* ({ olds }) {
70
- return yield* readRole(olds);
72
+ /** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
73
+ read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
74
+ const found = yield* readRole(olds);
75
+ const ours = Effect.sync(() => found !== undefined && matches(found, olds));
76
+ return yield* ownedRead({ fqn, instanceId, output }, found, ours);
71
77
  }),
72
78
 
73
79
  /**
@@ -87,6 +93,7 @@ export const BaoAuthRoleProvider = () =>
87
93
  * opt into `RemovalPolicy.destroy()` or destroy the old role's accessors by hand.
88
94
  */
89
95
  const move = yield* judgeRename(IDENTITY, olds, news, output);
96
+ // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
90
97
  if (output === undefined) return undefined;
91
98
  if (move !== undefined) return { action: 'replace' } as const;
92
99
  if (!isResolved(news)) return undefined;
@@ -96,10 +103,13 @@ export const BaoAuthRoleProvider = () =>
96
103
  return { action: 'update' } as const;
97
104
  }),
98
105
 
99
- reconcile: Effect.fn(function* ({ news, output }) {
106
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
100
107
  // ⛔ An `update` across a rename the diff could not see — refused before any write.
101
108
  yield* guardRename(IDENTITY, news, output);
102
109
  const live = yield* readRole(news);
110
+ const path = readPath(news.name);
111
+ if (live !== undefined)
112
+ yield* refuseTakeover({ fqn, instanceId, output }, `Bao.AuthRole ${path}`);
103
113
  if (live === undefined || !matches(live, news)) {
104
114
  yield* baoWrite('PUT', readPath(news.name), writeBody(news));
105
115
  }
@@ -130,5 +140,5 @@ export const BaoAuthRoleProvider = () =>
130
140
  return undefined;
131
141
  }),
132
142
  }),
133
- ),
143
+ ).pipe(Effect.map(provingResumes)),
134
144
  );
@@ -21,6 +21,9 @@ import { isResolved } from 'alchemy/Diff';
21
21
  import * as Provider from 'alchemy/Provider';
22
22
  import * as Effect from 'effect/Effect';
23
23
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
24
+ import { refuseTakeover } from '../ownership/adopt.ts';
25
+ import { ownedRead } from '../ownership/probe.ts';
26
+ import { provingResumes } from '../ownership/resume.ts';
24
27
  import { groupKey, scopeOfResource } from './cloudflare-group-scope.ts';
25
28
  import { CloudflarePermissionGroups, type GroupRef } from './cloudflare-permission-groups.ts';
26
29
  import { resolvePolicies } from './cloudflare-policy.ts';
@@ -106,9 +109,15 @@ export const BaoCloudflareRoleProvider = () =>
106
109
  */
107
110
  list: () => Effect.succeed([]),
108
111
 
109
- read: Effect.fn(function* ({ olds }) {
112
+ /** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
113
+ read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
110
114
  const live = yield* readCloudflareRole(olds.mount, olds.name);
111
- return live === undefined ? undefined : attributesOf(olds, live);
115
+ const found = live === undefined ? undefined : attributesOf(olds, live);
116
+ const ours = Effect.gen(function* () {
117
+ if (live === undefined || refusalOf(olds) !== undefined) return false;
118
+ return differences(olds, yield* resolved(olds), live).length === 0;
119
+ });
120
+ return yield* ownedRead({ fqn, instanceId, output }, found, ours);
112
121
  }),
113
122
 
114
123
  /**
@@ -129,6 +138,7 @@ export const BaoCloudflareRoleProvider = () =>
129
138
  */
130
139
  diff: Effect.fn(function* ({ news, olds, output }) {
131
140
  const move = yield* judgeRename(IDENTITY, olds, news, output);
141
+ // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
132
142
  if (output === undefined) return undefined;
133
143
  if (move !== undefined) return { action: 'replace' } as const;
134
144
  if (!isResolved(news)) return undefined;
@@ -145,7 +155,7 @@ export const BaoCloudflareRoleProvider = () =>
145
155
  : ({ action: 'update' } as const);
146
156
  }),
147
157
 
148
- reconcile: Effect.fn(function* ({ news, output }) {
158
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
149
159
  const path = rolePath(news.mount, news.name);
150
160
  // ⛔ An `update` across a move the diff could not see — refused before any write.
151
161
  yield* guardRename(IDENTITY, news, output);
@@ -155,6 +165,9 @@ export const BaoCloudflareRoleProvider = () =>
155
165
  }
156
166
  const policies = yield* resolved(news);
157
167
  const live = yield* readCloudflareRole(news.mount, news.name);
168
+ if (live !== undefined) {
169
+ yield* refuseTakeover({ fqn, instanceId, output }, `Bao.CloudflareRole ${path}`);
170
+ }
158
171
  /**
159
172
  * ⛔ ADOPTING A ROLE THAT ALREADY MATCHES MUST NOT REWRITE IT. Alchemy's `adopted` action
160
173
  * routes through reconcile (policy.ts has the Apply.ts reading), so without this guard
@@ -199,5 +212,5 @@ export const BaoCloudflareRoleProvider = () =>
199
212
  return undefined;
200
213
  }),
201
214
  });
202
- }),
215
+ }).pipe(Effect.map(provingResumes)),
203
216
  );
@@ -82,3 +82,55 @@ export const runFailure = <A, E>(
82
82
  env: BaoEnvironment,
83
83
  effect: Effect.Effect<A, E, HttpClient.HttpClient>,
84
84
  ) => Effect.runPromise(provided(env, Effect.flip(effect)));
85
+
86
+ /**
87
+ * An injected crash between a write and its commit. Once armed, `crashNext()` fails the first read
88
+ * that follows a write with a 500 — a reconcile whose write landed and whose read-back, and so the
89
+ * engine's commit, never did — and `crashNext('write')` lands the next write and then answers it
90
+ * 500, for a reconcile that reads nothing back (Bao.Policy). Either leaves the `creating` /
91
+ * `replacing` row a killed deploy leaves.
92
+ */
93
+ export const crashAfterWrite = () => {
94
+ let armed: 'read-back' | 'write' | undefined;
95
+ let wrote = false;
96
+ const CRASH: Reply = { json: { errors: ['injected: the deploy died here'] }, status: 500 };
97
+ return {
98
+ crashNext: (at: 'read-back' | 'write' = 'read-back') => {
99
+ armed = at;
100
+ wrote = false;
101
+ },
102
+ wrap:
103
+ (answer: (seen: Seen) => Reply) =>
104
+ (seen: Seen): Reply => {
105
+ if (armed === 'write' && seen.method !== 'GET') {
106
+ armed = undefined;
107
+ answer(seen);
108
+ return CRASH;
109
+ }
110
+ if (armed !== undefined && seen.method !== 'GET') wrote = true;
111
+ if (armed !== undefined && wrote && seen.method === 'GET') {
112
+ armed = undefined;
113
+ return CRASH;
114
+ }
115
+ return answer(seen);
116
+ },
117
+ };
118
+ };
119
+
120
+ /**
121
+ * A deploy killed BEFORE a resource's reconcile ran: the first write to a path ending in `suffix` is
122
+ * refused (500) without landing. Aimed at an upstream, it fails every create waiting on it after
123
+ * Apply has committed their `creating` rows — rows whose create never asked whose object sat at
124
+ * its identity (ownership/whole.ts).
125
+ */
126
+ export const refuseWriteOnce = (suffix: string) => {
127
+ let armed = true;
128
+ return (answer: (seen: Seen) => Reply) =>
129
+ (seen: Seen): Reply => {
130
+ if (armed && seen.method !== 'GET' && seen.path.endsWith(suffix)) {
131
+ armed = false;
132
+ return { json: { errors: ['injected: the upstream was refused'] }, status: 500 };
133
+ }
134
+ return answer(seen);
135
+ };
136
+ };
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The five families fake-families.ts does not cover — Bao.Policy, Bao.CloudflareRole,
3
+ * Bao.ProxmoxRole, Bao.Mount and Bao.AuthMethod — as rows of one table over one routed fake, so the
4
+ * ownership tests (adopt-core.test.ts) drive each through Alchemy's own plan and apply. Each row
5
+ * declares one object whose identity is fixed; `knob` is a duration-like prop that is NOT its
6
+ * identity, so an Output can stand in for it and a second owner can change it.
7
+ *
8
+ * ⛔ TEST-ONLY. Every value is a placeholder, not the estate's.
9
+ */
10
+ import * as Effect from 'effect/Effect';
11
+ import * as Layer from 'effect/Layer';
12
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
13
+ import type * as Output from 'alchemy/Output';
14
+ import { BaoAuthMethod, BaoAuthMethodProvider } from './auth-method.ts';
15
+ import { BaoAuthRoleProvider } from './auth-role.ts';
16
+ import { groupKey } from './cloudflare-group-scope.ts';
17
+ import { CloudflarePermissionGroups } from './cloudflare-permission-groups.ts';
18
+ import { BaoCloudflareRole, BaoCloudflareRoleProvider } from './cloudflare-role.ts';
19
+ import type { Reply, Seen } from './fake-bao.ts';
20
+ import { aclPolicies, cloudflareRoles, fakePolicyProviders, proxmoxRoles } from './fake-engines.ts';
21
+ import { wireRoles } from './fake-engines-roles.ts';
22
+ import { liveTable } from './fake-mounts.ts';
23
+ import { type FakeStack, type StackBody, fakeStack, withFakeStack } from './fake-stack.ts';
24
+ import { BaoMount, BaoMountProvider } from './mount.ts';
25
+ import { BaoPolicy } from './policy.ts';
26
+ import { BaoProxmoxRole, BaoProxmoxRoleProvider } from './proxmox-role.ts';
27
+
28
+ /** Every group resolves, to an id named after it — these tests are about ownership, not groups. */
29
+ const groups = Layer.succeed(CloudflarePermissionGroups, {
30
+ resolve: (_mount, refs) =>
31
+ Effect.succeed(new Map(refs.map((ref) => [groupKey(ref.name, ref.scope), `id-${ref.name}`]))),
32
+ });
33
+
34
+ export const coreProviders = Layer.mergeAll(
35
+ fakePolicyProviders,
36
+ BaoCloudflareRoleProvider().pipe(Layer.provide(groups)),
37
+ BaoProxmoxRoleProvider(),
38
+ BaoMountProvider(),
39
+ BaoAuthMethodProvider(),
40
+ // ★ For `upstream`: an AppRole whose `tokenTtl` is the Output a knob is fed.
41
+ BaoAuthRoleProvider(),
42
+ FetchHttpClient.layer,
43
+ );
44
+
45
+ /** Every store behind one answer, routed by path. */
46
+ export const coreEstate = () => {
47
+ const stores = {
48
+ auth: liveTable('sys/auth', {}),
49
+ cloudflare: cloudflareRoles(),
50
+ mounts: liveTable('sys/mounts', {}),
51
+ policies: aclPolicies(),
52
+ proxmox: proxmoxRoles(),
53
+ roles: wireRoles(),
54
+ };
55
+ const answer = (seen: Seen): Reply => {
56
+ const path = seen.path.replace(/^\/v1\//, '');
57
+ if (path.startsWith('sys/policies/acl/')) return stores.policies(seen);
58
+ if (path.startsWith('sys/auth')) return stores.auth.answer(seen);
59
+ if (path.startsWith('sys/mounts') || path.startsWith('sys/remount')) {
60
+ return stores.mounts.answer(seen);
61
+ }
62
+ if (path.startsWith('cloudflare-acme-dns/')) return stores.cloudflare(seen);
63
+ if (path.startsWith('proxmox-lab/')) return stores.proxmox(seen);
64
+ return stores.roles(seen);
65
+ };
66
+ return Object.assign(answer, stores);
67
+ };
68
+
69
+ export type CoreEstate = ReturnType<typeof coreEstate>;
70
+
71
+ type Text = string | Output.Output<string>;
72
+
73
+ export interface CoreRow {
74
+ readonly family: string;
75
+ /** One resource, logical id `id`, at the row's fixed identity. */
76
+ readonly declare: (id: string, knob: Text) => StackBody;
77
+ /** Whether the fake holds the object. */
78
+ readonly has: (estate: CoreEstate) => boolean;
79
+ }
80
+
81
+ export const CORE: readonly CoreRow[] = [
82
+ {
83
+ // ★ The fragments directory IS the content (fake-engines.ts), so a knob changes the policy.
84
+ declare: (id, knob) => BaoPolicy(id, { fragments: knob, name: 'app' }),
85
+ family: 'Bao.Policy',
86
+ has: (estate) => estate.policies.live.has('sys/policies/acl/app'),
87
+ },
88
+ {
89
+ declare: (id, knob) =>
90
+ BaoCloudflareRole(id, {
91
+ description: 'Read DNS records on one zone. (zone: example.com)',
92
+ maxTtl: '1h',
93
+ mount: 'cloudflare-acme-dns',
94
+ name: 'dns-read',
95
+ policies: [
96
+ {
97
+ effect: 'allow',
98
+ groupOrder: 'declared',
99
+ groups: ['DNS Read'],
100
+ resource: 'com.cloudflare.api.account.zone.z1',
101
+ },
102
+ ],
103
+ ttl: knob,
104
+ }),
105
+ family: 'Bao.CloudflareRole',
106
+ has: (estate) => estate.cloudflare.live.has('cloudflare-acme-dns/roles/dns-read'),
107
+ },
108
+ {
109
+ declare: (id, knob) =>
110
+ BaoProxmoxRole(id, {
111
+ maxTtl: '6h',
112
+ mintUser: 'reader@pve',
113
+ mount: 'proxmox-lab',
114
+ name: 'read',
115
+ ttl: knob,
116
+ }),
117
+ family: 'Bao.ProxmoxRole',
118
+ has: (estate) => estate.proxmox.live.has('proxmox-lab/roles/read'),
119
+ },
120
+ {
121
+ declare: (id, knob) => BaoMount(id, { defaultLeaseTtl: knob, path: 'kv', type: 'kv' }),
122
+ family: 'Bao.Mount',
123
+ has: (estate) => estate.mounts.table.has('kv/'),
124
+ },
125
+ {
126
+ declare: (id, knob) => BaoAuthMethod(id, { defaultLeaseTtl: knob, path: 'jwt', type: 'jwt' }),
127
+ family: 'Bao.AuthMethod',
128
+ has: (estate) => estate.auth.table.has('jwt/'),
129
+ },
130
+ ];
131
+
132
+ /** One stack over a fresh core estate, plus `owner`: a second stack over the same fake. */
133
+ export const withCore = (
134
+ body: (stack: FakeStack, estate: CoreEstate, seen: Seen[], owner: FakeStack) => Promise<void>,
135
+ answer: (estate: CoreEstate) => (seen: Seen) => Reply = (estate) => estate,
136
+ ): Promise<void> => {
137
+ const estate = coreEstate();
138
+ return withFakeStack(coreProviders, answer(estate), (stack, bao) =>
139
+ body(stack, estate, bao.seen, fakeStack(coreProviders, { BAO_ADDR: bao.address }, 'Owner')),
140
+ );
141
+ };
@@ -13,9 +13,9 @@ import * as Effect from 'effect/Effect';
13
13
  import * as Layer from 'effect/Layer';
14
14
  import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
15
15
  import { BaoAuthRole, BaoAuthRoleProvider } from './auth-role.ts';
16
- import type { Seen } from './fake-bao.ts';
16
+ import type { Reply, Seen } from './fake-bao.ts';
17
17
  import { type Estate, fakeEstate } from './fake-engines-roles.ts';
18
- import { type FakeStack, type StackBody, withFakeStack } from './fake-stack.ts';
18
+ import { type FakeStack, type StackBody, fakeStack, withFakeStack } from './fake-stack.ts';
19
19
  import { BaoJwtAuthConfig, BaoJwtAuthConfigProvider } from './jwt-config.ts';
20
20
  import { BaoJwtRole, BaoJwtRoleProvider } from './jwt-role.ts';
21
21
  import { BaoKubernetesRole, BaoKubernetesRoleProvider } from './kubernetes-role.ts';
@@ -38,12 +38,18 @@ export const familyProviders = Layer.mergeAll(
38
38
  FetchHttpClient.layer,
39
39
  );
40
40
 
41
- /** One stack over a fresh estate: every family's provider, and every call the fake saw. */
41
+ /**
42
+ * One stack over a fresh estate: every family's provider, and every call the fake saw — plus
43
+ * `owner`, a SECOND stack over the same fake with its own state: another owner of the same names.
44
+ */
42
45
  export const withEstate = (
43
- body: (stack: FakeStack, estate: Estate, seen: Seen[]) => Promise<void>,
46
+ body: (stack: FakeStack, estate: Estate, seen: Seen[], owner: FakeStack) => Promise<void>,
47
+ answer: (estate: Estate) => (seen: Seen) => Reply = (estate) => estate,
44
48
  ): Promise<void> => {
45
49
  const estate = fakeEstate();
46
- return withFakeStack(familyProviders, estate, (stack, bao) => body(stack, estate, bao.seen));
50
+ return withFakeStack(familyProviders, answer(estate), (stack, bao) =>
51
+ body(stack, estate, bao.seen, fakeStack(familyProviders, { BAO_ADDR: bao.address }, 'Owner')),
52
+ );
47
53
  };
48
54
 
49
55
  /** The row for one family, by name. */
@@ -0,0 +1,74 @@
1
+ /**
2
+ * A fake mount table for fake-bao — `sys/mounts` (secrets engines) or `sys/auth` (auth methods):
3
+ * enable, tune, read, the listing a 400 is settled against (mount-wire.ts), and `sys/remount`.
4
+ * Split out of mount-move.test.ts so the engine-level ownership tests drive Bao.Mount and
5
+ * Bao.AuthMethod through the same table.
6
+ *
7
+ * ⛔ TEST-ONLY — see fake-bao.ts. No provider imports this file.
8
+ */
9
+ import type { Reply, Seen } from './fake-bao.ts';
10
+
11
+ type Entry = { type: string; description: string; config: Record<string, unknown> };
12
+
13
+ /** A fresh mount's lease TTLs: 0, "use the system default", as the server reports them. */
14
+ const zero = () => ({ default_lease_ttl: 0, max_lease_ttl: 0 });
15
+
16
+ /** The table keyed `<path>/` as the server keys it; `initial` maps a bare path to its type. */
17
+ export const liveTable = (prefix: string, initial: Record<string, string>) => {
18
+ const entries = new Map<string, Entry>(
19
+ Object.entries(initial).map(([path, type]) => [
20
+ `${path}/`,
21
+ { config: zero(), description: '', type },
22
+ ]),
23
+ );
24
+ const types = () => new Map([...entries].map(([path, entry]) => [path, entry.type]));
25
+ const answer = (seen: Seen): Reply => {
26
+ const path = seen.path.replace(/^\/v1\//, '');
27
+ if (path === 'sys/remount' && seen.method === 'POST') {
28
+ const { from, to } = JSON.parse(seen.body) as { from: string; to: string };
29
+ const key = `${from.replace(/^auth\//, '')}/`;
30
+ const entry = entries.get(key);
31
+ if (entry === undefined) return { json: { errors: ['no matching mount'] }, status: 400 };
32
+ entries.delete(key);
33
+ entries.set(`${to.replace(/^auth\//, '')}/`, entry);
34
+ return { json: { data: { migration_id: 'm-1' } }, status: 200 };
35
+ }
36
+ if (path.startsWith('sys/remount/status/')) {
37
+ return { json: { data: { migration_info: { status: 'success' } } }, status: 200 };
38
+ }
39
+ if (path === prefix) return { json: { data: Object.fromEntries(types()) }, status: 200 };
40
+ const tune = path.endsWith('/tune');
41
+ const key = `${path.slice(prefix.length + 1).replace(/\/tune$/, '')}/`;
42
+ if (seen.method === 'POST') {
43
+ const body = JSON.parse(seen.body || '{}') as Record<string, unknown>;
44
+ const entry = entries.get(key) ?? {
45
+ config: zero(),
46
+ description: '',
47
+ type: 'enabled-by-test',
48
+ };
49
+ if (!tune && typeof body['type'] === 'string') entry.type = body['type'];
50
+ if (tune) {
51
+ const { description, ...config } = body;
52
+ if (typeof description === 'string') entry.description = description;
53
+ entry.config = { ...entry.config, ...config };
54
+ }
55
+ entries.set(key, entry);
56
+ return { status: 204 };
57
+ }
58
+ if (seen.method === 'DELETE') {
59
+ entries.delete(key);
60
+ return { status: 204 };
61
+ }
62
+ const entry = entries.get(key);
63
+ return entry === undefined
64
+ ? { json: { errors: [`No secret engine mount at ${key}`] }, status: 400 }
65
+ : { json: { data: entry }, status: 200 };
66
+ };
67
+ return {
68
+ answer,
69
+ /** The live table as `<path>/` → type, the shape the move tests compare. */
70
+ get table() {
71
+ return types();
72
+ },
73
+ };
74
+ };
@@ -16,6 +16,8 @@
16
16
  * in the shell running the tests is never read. No provider imports this file.
17
17
  */
18
18
  import * as Alchemy from 'alchemy';
19
+ import { AdoptPolicy } from 'alchemy/AdoptPolicy';
20
+ import { provideFreshArtifactStore } from 'alchemy/Artifacts';
19
21
  import * as Effect from 'effect/Effect';
20
22
  import * as Layer from 'effect/Layer';
21
23
  import type { BaoEnvironment } from './bao-address.ts';
@@ -28,9 +30,12 @@ export type Planned = Readonly<Record<string, string>>;
28
30
  /** A stack body: resource declarations, as an `alchemy.run.ts` would write them. */
29
31
  export type StackBody = Effect.Effect<unknown, unknown, unknown>;
30
32
 
33
+ /** What the CLI would set for one deploy: `adopt` is `--adopt` (the AdoptPolicy service). */
34
+ export type DeployOptions = { readonly adopt?: boolean };
35
+
31
36
  export interface FakeStack {
32
37
  /** Plan, then apply, `body`. Resolves to the plan; rejects with the apply's failure. */
33
- readonly deploy: (body: StackBody) => Promise<Planned>;
38
+ readonly deploy: (body: StackBody, options?: DeployOptions) => Promise<Planned>;
34
39
  }
35
40
 
36
41
  type Node = { readonly action: string };
@@ -72,22 +77,30 @@ export const fakeStack = <ROut, E, RIn>(
72
77
  ) => Effect.Effect<PlanView, unknown, never>;
73
78
  const apply = Alchemy.apply as unknown as (planned: PlanView) => Effect.Effect<unknown, unknown>;
74
79
 
75
- const deploy = (body: StackBody) =>
80
+ /**
81
+ * ★ ONE ARTIFACT STORE ACROSS PLAN AND APPLY, as Deploy.ts's evalStack provides — the channel a
82
+ * diff uses to tell reconcile it resumes an interrupted create (ownership/resume.ts).
83
+ * ★ `--adopt` reaches registration, plan and apply alike, as the Alchemist session provides it.
84
+ */
85
+ const deploy = (body: StackBody, options: DeployOptions = {}) =>
76
86
  Effect.gen(function* () {
77
87
  const compiled = yield* stack(name, { providers, state }, body);
78
88
  return yield* Effect.gen(function* () {
79
89
  const planned = yield* plan(compiled);
80
90
  yield* apply(planned);
81
91
  return actionsOf(planned);
82
- }).pipe(Effect.provide(Layer.succeedContext(compiled.services)));
92
+ }).pipe(provideFreshArtifactStore, Effect.provide(Layer.succeedContext(compiled.services)));
83
93
  }).pipe(
94
+ options.adopt === undefined ? (e) => e : Effect.provideService(AdoptPolicy, options.adopt),
84
95
  Effect.provideService(Alchemy.Stage, 'test'),
85
96
  Effect.provideService(BaoEnv, env),
86
97
  Effect.provide(state),
87
98
  Effect.scoped,
88
99
  );
89
100
 
90
- return { deploy: (body) => Effect.runPromise(deploy(body) as Effect.Effect<Planned>) };
101
+ return {
102
+ deploy: (body, options) => Effect.runPromise(deploy(body, options) as Effect.Effect<Planned>),
103
+ };
91
104
  };
92
105
 
93
106
  /**
@@ -21,6 +21,8 @@ import { isResolved } from 'alchemy/Diff';
21
21
  import * as Provider from 'alchemy/Provider';
22
22
  import * as Effect from 'effect/Effect';
23
23
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
24
+ import { claimFor } from '../ownership/adopt.ts';
25
+ import { provingResumes } from '../ownership/resume.ts';
24
26
  import {
25
27
  type BaoJwtAuthConfigAttributes,
26
28
  type BaoJwtAuthConfigProps,
@@ -33,7 +35,7 @@ import {
33
35
  } from './jwt-config-form.ts';
34
36
  import { declaredOr } from './rename.ts';
35
37
  import { type Identity, guardRename, judgeRename } from './rename-identity.ts';
36
- import { type RoleSpec, planRole, readRoleAt, reconcileRole } from './role-reconcile.ts';
38
+ import { type RoleSpec, planRole, readOwnedRole, reconcileRole } from './role-reconcile.ts';
37
39
 
38
40
  export type { BaoJwtAuthConfigAttributes, BaoJwtAuthConfigProps } from './jwt-config-form.ts';
39
41
 
@@ -78,14 +80,15 @@ export const BaoJwtAuthConfigProvider = () =>
78
80
  BaoJwtAuthConfig.Provider.of({
79
81
  list: () => Effect.succeed([]),
80
82
 
81
- read: Effect.fn(function* ({ olds }) {
82
- const found = yield* readRoleAt(jwtConfigSpec(olds));
83
- return found?.attributes;
83
+ /** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
84
+ read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
85
+ return yield* readOwnedRole({ fqn, instanceId, output }, jwtConfigSpec(olds));
84
86
  }),
85
87
 
86
88
  diff: Effect.fn(function* ({ news, olds, output }) {
87
89
  // ⛔ The mount first, before isResolved; onto a mount with a config fails the plan.
88
90
  const move = yield* judgeRename(IDENTITY, olds, news, output);
91
+ // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
89
92
  if (output === undefined) return undefined;
90
93
  if (move !== undefined) return { action: 'replace' } as const;
91
94
  if (!isResolved(news)) return undefined;
@@ -93,14 +96,14 @@ export const BaoJwtAuthConfigProvider = () =>
93
96
  }),
94
97
 
95
98
  /** ⛔ Refuses, before writing, a live config that carries an OIDC client (wouldErase). */
96
- reconcile: Effect.fn(function* ({ news, output }) {
99
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
97
100
  // ⛔ An `update` across a move the diff could not see — refused before any write.
98
101
  yield* guardRename(IDENTITY, news, output);
99
- return yield* reconcileRole(jwtConfigSpec(news));
102
+ return yield* reconcileRole(jwtConfigSpec(news), claimFor({ fqn, instanceId, output }));
100
103
  }),
101
104
 
102
105
  /** ⚠️ Writes nothing — there is no delete endpoint (header). */
103
106
  delete: () => Effect.void,
104
107
  }),
105
- ),
108
+ ).pipe(Effect.map(provingResumes)),
106
109
  );
@@ -23,6 +23,8 @@ import { isResolved } from 'alchemy/Diff';
23
23
  import * as Provider from 'alchemy/Provider';
24
24
  import * as Effect from 'effect/Effect';
25
25
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
26
+ import { claimFor } from '../ownership/adopt.ts';
27
+ import { provingResumes } from '../ownership/resume.ts';
26
28
  import { baoDelete } from './bao-http.ts';
27
29
  import {
28
30
  type BaoJwtRoleAttributes,
@@ -34,7 +36,7 @@ import {
34
36
  writeBody,
35
37
  } from './jwt-role-form.ts';
36
38
  import { foldName, guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
37
- import { type RoleSpec, planRole, readRoleAt, reconcileRole } from './role-reconcile.ts';
39
+ import { type RoleSpec, planRole, readOwnedRole, reconcileRole } from './role-reconcile.ts';
38
40
 
39
41
  export type {
40
42
  BaoJwtCallbackMode,
@@ -87,25 +89,26 @@ export const BaoJwtRoleProvider = () =>
87
89
  */
88
90
  list: () => Effect.succeed([]),
89
91
 
90
- read: Effect.fn(function* ({ olds }) {
91
- const found = yield* readRoleAt(jwtRoleSpec(olds));
92
- 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));
93
95
  }),
94
96
 
95
97
  /** ⛔ IT COMPARES THE LIVE ROLE, NOT THE STORED DIGEST — a hand-widened audience is drift. */
96
98
  diff: Effect.fn(function* ({ news, olds, output }) {
97
99
  // ⛔ The identity first, before isResolved; onto a role that exists fails the plan.
98
100
  const move = yield* judgeRename(IDENTITY, olds, news, output);
101
+ // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
99
102
  if (output === undefined) return undefined;
100
103
  if (move !== undefined) return { action: 'replace' } as const;
101
104
  if (!isResolved(news)) return undefined;
102
105
  return { action: yield* planRole(jwtRoleSpec(news)) } as const;
103
106
  }),
104
107
 
105
- reconcile: Effect.fn(function* ({ news, output }) {
108
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
106
109
  // ⛔ An `update` across a move the diff could not see — refused before any write.
107
110
  yield* guardRename(IDENTITY, news, output);
108
- return yield* reconcileRole(jwtRoleSpec(news));
111
+ return yield* reconcileRole(jwtRoleSpec(news), claimFor({ fqn, instanceId, output }));
109
112
  }),
110
113
 
111
114
  /** Idempotent as Alchemy requires — a missing role deletes as success. */
@@ -114,5 +117,5 @@ export const BaoJwtRoleProvider = () =>
114
117
  return undefined;
115
118
  }),
116
119
  }),
117
- ),
120
+ ).pipe(Effect.map(provingResumes)),
118
121
  );