@homeflare/alchemy 0.8.0 → 0.10.1

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 (247) 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 +370 -0
  121. package/dist/verify/cli.js.map +17 -0
  122. package/dist/verify/fake-engine.d.ts +65 -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 +347 -0
  127. package/dist/verify/index.js.map +16 -0
  128. package/dist/verify/plan-scope.d.ts +14 -0
  129. package/dist/verify/plan-scope.d.ts.map +1 -0
  130. package/dist/verify/recheck.d.ts +42 -0
  131. package/dist/verify/recheck.d.ts.map +1 -0
  132. package/dist/verify/report.d.ts +6 -0
  133. package/dist/verify/report.d.ts.map +1 -0
  134. package/dist/verify/rows.d.ts +67 -0
  135. package/dist/verify/rows.d.ts.map +1 -0
  136. package/dist/verify/spy.d.ts +43 -0
  137. package/dist/verify/spy.d.ts.map +1 -0
  138. package/dist/verify/verify.d.ts +43 -0
  139. package/dist/verify/verify.d.ts.map +1 -0
  140. package/docs/adopt-verify.md +119 -0
  141. package/docs/adopted-deploys.md +85 -0
  142. package/docs/credentials.md +15 -0
  143. package/docs/launchd-sudo.md +31 -6
  144. package/docs/launchd.md +7 -3
  145. package/docs/ownership.md +113 -0
  146. package/docs/proxmox-lxc.md +196 -0
  147. package/docs/proxmox.md +12 -0
  148. package/package.json +9 -2
  149. package/src/caddy/adopt-scope.test.ts +1 -1
  150. package/src/caddy/config-lifecycle.ts +1 -1
  151. package/src/caddy/config.ts +2 -1
  152. package/src/launchd/adopt-apply.test.ts +166 -0
  153. package/src/launchd/fake-runner.ts +26 -4
  154. package/src/launchd/host-effect.ts +1 -33
  155. package/src/launchd/host-file-lifecycle.ts +39 -14
  156. package/src/launchd/host-file.ts +13 -4
  157. package/src/launchd/job-lifecycle.ts +22 -14
  158. package/src/launchd/job-preflight.ts +18 -1
  159. package/src/launchd/job.ts +13 -4
  160. package/src/launchd/runner.ts +7 -0
  161. package/src/launchd/sudo-acl.ts +68 -0
  162. package/src/launchd/sudo-chain.test.ts +121 -0
  163. package/src/launchd/sudo-guard.ts +89 -10
  164. package/src/launchd/sudo-modes.test.ts +135 -0
  165. package/src/launchd/sudo-runner.ts +40 -59
  166. package/src/launchd/sudo-said.ts +58 -0
  167. package/src/openbao/README.md +5 -2
  168. package/src/openbao/REPLACE.md +13 -11
  169. package/src/openbao/adopt-core.test.ts +119 -0
  170. package/src/openbao/adopt-families.test.ts +97 -0
  171. package/src/openbao/adopt-holes.test.ts +168 -0
  172. package/src/openbao/adopt-recovery.test.ts +71 -0
  173. package/src/openbao/auth-method-reconcile.ts +15 -3
  174. package/src/openbao/auth-method.ts +15 -6
  175. package/src/openbao/auth-role.ts +14 -4
  176. package/src/openbao/cloudflare-role.ts +17 -4
  177. package/src/openbao/fake-bao.ts +52 -0
  178. package/src/openbao/fake-core.ts +141 -0
  179. package/src/openbao/fake-families.ts +11 -5
  180. package/src/openbao/fake-mounts.ts +74 -0
  181. package/src/openbao/fake-stack.ts +17 -4
  182. package/src/openbao/jwt-config.ts +10 -7
  183. package/src/openbao/jwt-role.ts +10 -7
  184. package/src/openbao/kubernetes-role.ts +13 -7
  185. package/src/openbao/mfa-enforcement.ts +20 -6
  186. package/src/openbao/mfa-totp.ts +22 -7
  187. package/src/openbao/mount-move.test.ts +2 -35
  188. package/src/openbao/mount-reconcile.ts +13 -2
  189. package/src/openbao/mount.ts +15 -6
  190. package/src/openbao/pki-role.ts +15 -4
  191. package/src/openbao/plugin-reconcile.ts +16 -5
  192. package/src/openbao/plugin.ts +17 -6
  193. package/src/openbao/policy.ts +17 -4
  194. package/src/openbao/proxmox-role.ts +13 -4
  195. package/src/openbao/rename-adoption.test.ts +32 -19
  196. package/src/openbao/role-reconcile.ts +23 -0
  197. package/src/openbao/ssh-role.ts +14 -4
  198. package/src/ownership/adopt.ts +122 -0
  199. package/src/ownership/ownership.test.ts +200 -0
  200. package/src/ownership/probe.ts +65 -0
  201. package/src/ownership/resume.ts +118 -0
  202. package/src/ownership/rows.ts +117 -0
  203. package/src/ownership/whole.test.ts +39 -0
  204. package/src/ownership/whole.ts +60 -0
  205. package/src/proxmox/adopt-noop.test.ts +112 -0
  206. package/src/proxmox/ceph-pool-adopt.test.ts +120 -0
  207. package/src/proxmox/ceph-pool-form.ts +3 -3
  208. package/src/proxmox/ceph-pool.ts +10 -3
  209. package/src/proxmox/fake-bao-env.ts +28 -0
  210. package/src/proxmox/fake-pve-lxc.ts +210 -0
  211. package/src/proxmox/fake-pve.ts +75 -0
  212. package/src/proxmox/index.test.ts +6 -6
  213. package/src/proxmox/index.ts +6 -2
  214. package/src/proxmox/lxc-adopt.test.ts +170 -0
  215. package/src/proxmox/lxc-create-form.ts +76 -0
  216. package/src/proxmox/lxc-create.test.ts +126 -0
  217. package/src/proxmox/lxc-guards.test.ts +158 -0
  218. package/src/proxmox/lxc-harness.ts +150 -0
  219. package/src/proxmox/lxc-identity.ts +43 -0
  220. package/src/proxmox/lxc-judge.test.ts +175 -0
  221. package/src/proxmox/lxc-judge.ts +196 -0
  222. package/src/proxmox/lxc-lifecycle.ts +200 -0
  223. package/src/proxmox/lxc-ownership.test.ts +159 -0
  224. package/src/proxmox/lxc-props.ts +215 -0
  225. package/src/proxmox/lxc-read.test.ts +60 -0
  226. package/src/proxmox/lxc-volume.test.ts +96 -0
  227. package/src/proxmox/lxc-volume.ts +153 -0
  228. package/src/proxmox/lxc-wire.ts +210 -0
  229. package/src/proxmox/lxc.ts +216 -93
  230. package/src/proxmox/network-apply-read.ts +3 -1
  231. package/src/proxmox/pbs-datastore.ts +4 -3
  232. package/src/proxmox/resource.ts +7 -7
  233. package/src/proxmox/update-guard.ts +31 -0
  234. package/src/verify/args.ts +82 -0
  235. package/src/verify/cli.test.ts +86 -0
  236. package/src/verify/cli.ts +49 -0
  237. package/src/verify/fake-engine.ts +182 -0
  238. package/src/verify/index.ts +37 -0
  239. package/src/verify/plan-scope.ts +34 -0
  240. package/src/verify/recheck.test.ts +56 -0
  241. package/src/verify/recheck.ts +71 -0
  242. package/src/verify/report.ts +52 -0
  243. package/src/verify/rows.ts +210 -0
  244. package/src/verify/spy.test.ts +141 -0
  245. package/src/verify/spy.ts +160 -0
  246. package/src/verify/verify.test.ts +146 -0
  247. package/src/verify/verify.ts +185 -0
@@ -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
  );
@@ -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
  );