@homeflare/alchemy 0.7.0 → 0.8.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 (137) hide show
  1. package/README.md +26 -37
  2. package/dist/caddy/config-lifecycle.d.ts +49 -3
  3. package/dist/caddy/config-lifecycle.d.ts.map +1 -1
  4. package/dist/caddy/config.d.ts +6 -2
  5. package/dist/caddy/config.d.ts.map +1 -1
  6. package/dist/caddy/fake-caddy.d.ts +11 -0
  7. package/dist/caddy/fake-caddy.d.ts.map +1 -1
  8. package/dist/caddy/index.d.ts +2 -1
  9. package/dist/caddy/index.d.ts.map +1 -1
  10. package/dist/caddy/index.js +71 -13
  11. package/dist/caddy/index.js.map +9 -9
  12. package/dist/caddy/with-file.d.ts +7 -3
  13. package/dist/caddy/with-file.d.ts.map +1 -1
  14. package/dist/launchd/fake-sudo.d.ts +50 -0
  15. package/dist/launchd/fake-sudo.d.ts.map +1 -0
  16. package/dist/launchd/host-effect.d.ts +18 -9
  17. package/dist/launchd/host-effect.d.ts.map +1 -1
  18. package/dist/launchd/host-file-form.d.ts +5 -0
  19. package/dist/launchd/host-file-form.d.ts.map +1 -1
  20. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  21. package/dist/launchd/index.d.ts +2 -0
  22. package/dist/launchd/index.d.ts.map +1 -1
  23. package/dist/launchd/index.js +342 -8
  24. package/dist/launchd/index.js.map +15 -11
  25. package/dist/launchd/job-preflight.d.ts.map +1 -1
  26. package/dist/launchd/job-validate.d.ts +2 -0
  27. package/dist/launchd/job-validate.d.ts.map +1 -1
  28. package/dist/launchd/local-runner.d.ts +2 -0
  29. package/dist/launchd/local-runner.d.ts.map +1 -1
  30. package/dist/launchd/providers.d.ts +2 -1
  31. package/dist/launchd/providers.d.ts.map +1 -1
  32. package/dist/launchd/runner.d.ts +7 -5
  33. package/dist/launchd/runner.d.ts.map +1 -1
  34. package/dist/launchd/sudo-allowlist.d.ts +47 -0
  35. package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
  36. package/dist/launchd/sudo-guard.d.ts +56 -0
  37. package/dist/launchd/sudo-guard.d.ts.map +1 -0
  38. package/dist/launchd/sudo-runner.d.ts +60 -0
  39. package/dist/launchd/sudo-runner.d.ts.map +1 -0
  40. package/dist/launchd/sudo-stage.d.ts +7 -0
  41. package/dist/launchd/sudo-stage.d.ts.map +1 -0
  42. package/dist/openbao/auth-role-form.d.ts +0 -9
  43. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  44. package/dist/openbao/auth-role.d.ts +3 -2
  45. package/dist/openbao/auth-role.d.ts.map +1 -1
  46. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  47. package/dist/openbao/fake-engines-roles.d.ts +32 -0
  48. package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
  49. package/dist/openbao/fake-engines.d.ts +8 -2
  50. package/dist/openbao/fake-engines.d.ts.map +1 -1
  51. package/dist/openbao/fake-families.d.ts +49 -0
  52. package/dist/openbao/fake-families.d.ts.map +1 -0
  53. package/dist/openbao/host-approles.d.ts.map +1 -1
  54. package/dist/openbao/index.js +359 -280
  55. package/dist/openbao/index.js.map +21 -19
  56. package/dist/openbao/jwt-config.d.ts +5 -0
  57. package/dist/openbao/jwt-config.d.ts.map +1 -1
  58. package/dist/openbao/jwt-role.d.ts +2 -1
  59. package/dist/openbao/jwt-role.d.ts.map +1 -1
  60. package/dist/openbao/kubernetes-role.d.ts +2 -1
  61. package/dist/openbao/kubernetes-role.d.ts.map +1 -1
  62. package/dist/openbao/mfa-enforcement.d.ts +6 -3
  63. package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
  64. package/dist/openbao/mfa-totp.d.ts.map +1 -1
  65. package/dist/openbao/pki-role.d.ts.map +1 -1
  66. package/dist/openbao/plugin.d.ts.map +1 -1
  67. package/dist/openbao/policy.d.ts.map +1 -1
  68. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  69. package/dist/openbao/rename-identity.d.ts +69 -0
  70. package/dist/openbao/rename-identity.d.ts.map +1 -0
  71. package/dist/openbao/rename.d.ts +13 -12
  72. package/dist/openbao/rename.d.ts.map +1 -1
  73. package/dist/openbao/ssh-role-erase.d.ts +11 -0
  74. package/dist/openbao/ssh-role-erase.d.ts.map +1 -0
  75. package/dist/openbao/ssh-role.d.ts.map +1 -1
  76. package/docs/caddy-admin.md +24 -0
  77. package/docs/caddy.md +73 -54
  78. package/docs/launchd-sudo.md +122 -0
  79. package/docs/launchd.md +7 -4
  80. package/docs/peers.md +39 -0
  81. package/package.json +1 -1
  82. package/src/caddy/adopt-scope.test.ts +82 -0
  83. package/src/caddy/config-adopt.test.ts +235 -0
  84. package/src/caddy/config-lifecycle.test.ts +31 -53
  85. package/src/caddy/config-lifecycle.ts +98 -7
  86. package/src/caddy/config.ts +48 -11
  87. package/src/caddy/fake-caddy.ts +19 -0
  88. package/src/caddy/index.ts +2 -1
  89. package/src/caddy/providers.test.ts +8 -23
  90. package/src/caddy/with-file.ts +7 -3
  91. package/src/launchd/fake-sudo.ts +136 -0
  92. package/src/launchd/host-effect.ts +33 -1
  93. package/src/launchd/host-file-form.ts +10 -2
  94. package/src/launchd/host-file-lifecycle.ts +2 -1
  95. package/src/launchd/index.ts +2 -0
  96. package/src/launchd/job-preflight.ts +3 -2
  97. package/src/launchd/job-validate.ts +1 -1
  98. package/src/launchd/local-runner.ts +6 -3
  99. package/src/launchd/providers.test.ts +3 -0
  100. package/src/launchd/providers.ts +2 -1
  101. package/src/launchd/runner.ts +7 -5
  102. package/src/launchd/sudo-allowlist.test.ts +191 -0
  103. package/src/launchd/sudo-allowlist.ts +213 -0
  104. package/src/launchd/sudo-guard.test.ts +100 -0
  105. package/src/launchd/sudo-guard.ts +151 -0
  106. package/src/launchd/sudo-lifecycle.test.ts +172 -0
  107. package/src/launchd/sudo-runner-files.test.ts +240 -0
  108. package/src/launchd/sudo-runner.test.ts +126 -0
  109. package/src/launchd/sudo-runner.ts +235 -0
  110. package/src/launchd/sudo-stage.ts +28 -0
  111. package/src/openbao/README.md +8 -2
  112. package/src/openbao/REPLACE.md +65 -37
  113. package/src/openbao/auth-role-form.test.ts +2 -16
  114. package/src/openbao/auth-role-form.ts +0 -11
  115. package/src/openbao/auth-role.ts +35 -14
  116. package/src/openbao/cloudflare-role.ts +6 -14
  117. package/src/openbao/fake-engines-roles.ts +114 -0
  118. package/src/openbao/fake-engines.ts +18 -11
  119. package/src/openbao/fake-families.ts +182 -0
  120. package/src/openbao/host-approles.test.ts +15 -2
  121. package/src/openbao/host-approles.ts +19 -4
  122. package/src/openbao/jwt-config.ts +26 -5
  123. package/src/openbao/jwt-role.ts +24 -8
  124. package/src/openbao/kubernetes-role.ts +20 -8
  125. package/src/openbao/mfa-enforcement.ts +23 -8
  126. package/src/openbao/mfa-totp.ts +21 -8
  127. package/src/openbao/pki-role.ts +19 -7
  128. package/src/openbao/plugin.ts +41 -15
  129. package/src/openbao/policy.ts +7 -7
  130. package/src/openbao/proxmox-role.ts +7 -16
  131. package/src/openbao/rename-adoption.test.ts +82 -0
  132. package/src/openbao/rename-families.test.ts +122 -0
  133. package/src/openbao/rename-identity.test.ts +129 -0
  134. package/src/openbao/rename-identity.ts +117 -0
  135. package/src/openbao/rename.ts +19 -15
  136. package/src/openbao/ssh-role-erase.ts +50 -0
  137. package/src/openbao/ssh-role.ts +16 -54
@@ -14,7 +14,8 @@
14
14
  *
15
15
  * ★ REPLACE SEMANTICS (REPLACE.md): `mount` or `name` changed → `replace`, create-first. The new
16
16
  * path cannot collide with the old, so no `deleteFirst`. Under the default `retain` the old role
17
- * stays live, and still admits logins, until removed by hand.
17
+ * stays live, and still admits logins, until removed by hand. ⛔ A move onto a role that already
18
+ * exists fails the plan (rename-identity.ts).
18
19
  * ★ `defaultRemovalPolicy: 'retain'`, like every Bao.* family.
19
20
  */
20
21
  import { Resource } from 'alchemy';
@@ -28,11 +29,11 @@ import {
28
29
  type BaoJwtRoleProps,
29
30
  attributesOf,
30
31
  matches,
31
- mountOf,
32
32
  problems,
33
33
  rolePath,
34
34
  writeBody,
35
35
  } from './jwt-role-form.ts';
36
+ import { foldName, guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
36
37
  import { type RoleSpec, planRole, readRoleAt, reconcileRole } from './role-reconcile.ts';
37
38
 
38
39
  export type {
@@ -54,6 +55,18 @@ export const BaoJwtRole = Resource<BaoJwtRole>('Bao.JwtRole', {
54
55
  defaultRemovalPolicy: 'retain',
55
56
  });
56
57
 
58
+ /**
59
+ * The name folded, as the server keys it (`foldName`): `name` is a TypeLowerCaseString
60
+ * (builtin/credential/jwt/path_role.go:77), so the handler reads it lowercased before it ever builds
61
+ * `role/<name>` (:289). `App` → `app` is the same role, never a move; `problems` refuses the case.
62
+ */
63
+ const IDENTITY = roleIdentity<BaoJwtRoleAttributes>(
64
+ 'Bao.JwtRole',
65
+ (mount, name) => rolePath({ mount, name }),
66
+ 'jwt',
67
+ foldName,
68
+ );
69
+
57
70
  export const jwtRoleSpec = (props: BaoJwtRoleProps): RoleSpec<BaoJwtRoleAttributes> => ({
58
71
  attributesOf: (live) => attributesOf(props, live),
59
72
  body: writeBody(props),
@@ -80,15 +93,18 @@ export const BaoJwtRoleProvider = () =>
80
93
  }),
81
94
 
82
95
  /** ⛔ IT COMPARES THE LIVE ROLE, NOT THE STORED DIGEST — a hand-widened audience is drift. */
83
- diff: Effect.fn(function* ({ news, output }) {
84
- if (output === undefined || !isResolved(news)) return undefined;
85
- if (mountOf(news) !== output.mount || news.name !== output.name) {
86
- return { action: 'replace' } as const;
87
- }
96
+ diff: Effect.fn(function* ({ news, olds, output }) {
97
+ // ⛔ The identity first, before isResolved; onto a role that exists fails the plan.
98
+ const move = yield* judgeRename(IDENTITY, olds, news, output);
99
+ if (output === undefined) return undefined;
100
+ if (move !== undefined) return { action: 'replace' } as const;
101
+ if (!isResolved(news)) return undefined;
88
102
  return { action: yield* planRole(jwtRoleSpec(news)) } as const;
89
103
  }),
90
104
 
91
- reconcile: Effect.fn(function* ({ news }) {
105
+ reconcile: Effect.fn(function* ({ news, output }) {
106
+ // ⛔ An `update` across a move the diff could not see — refused before any write.
107
+ yield* guardRename(IDENTITY, news, output);
92
108
  return yield* reconcileRole(jwtRoleSpec(news));
93
109
  }),
94
110
 
@@ -9,7 +9,8 @@
9
9
  * and nothing else (path_role.go:239-256).
10
10
  *
11
11
  * ★ REPLACE SEMANTICS (REPLACE.md): `mount` or `name` changed → `replace`, create-first (the new
12
- * path cannot collide). Under the default `retain` the old role stays live until removed by hand.
12
+ * path cannot collide with the old). Under the default `retain` the old role stays live until
13
+ * removed by hand. ⛔ A move onto a role that already exists fails the plan (rename-identity.ts).
13
14
  */
14
15
  import { Resource } from 'alchemy';
15
16
  import { isResolved } from 'alchemy/Diff';
@@ -22,11 +23,11 @@ import {
22
23
  type BaoKubernetesRoleProps,
23
24
  attributesOf,
24
25
  matches,
25
- mountOf,
26
26
  problems,
27
27
  rolePath,
28
28
  writeBody,
29
29
  } from './kubernetes-role-form.ts';
30
+ import { foldName, guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
30
31
  import { type RoleSpec, planRole, readRoleAt, reconcileRole } from './role-reconcile.ts';
31
32
 
32
33
  export type {
@@ -47,6 +48,14 @@ export const BaoKubernetesRole = Resource<BaoKubernetesRole>('Bao.KubernetesRole
47
48
  defaultRemovalPolicy: 'retain',
48
49
  });
49
50
 
51
+ /** The name folded, as the server stores it (`foldName`). */
52
+ const IDENTITY = roleIdentity<BaoKubernetesRoleAttributes>(
53
+ 'Bao.KubernetesRole',
54
+ (mount, name) => rolePath({ mount, name }),
55
+ 'kubernetes',
56
+ foldName,
57
+ );
58
+
50
59
  export const kubernetesRoleSpec = (
51
60
  props: BaoKubernetesRoleProps,
52
61
  ): RoleSpec<BaoKubernetesRoleAttributes> => ({
@@ -72,15 +81,18 @@ export const BaoKubernetesRoleProvider = () =>
72
81
  }),
73
82
 
74
83
  /** ⛔ IT COMPARES THE LIVE ROLE, NOT THE STORED DIGEST. */
75
- diff: Effect.fn(function* ({ news, output }) {
76
- if (output === undefined || !isResolved(news)) return undefined;
77
- if (mountOf(news) !== output.mount || news.name !== output.name) {
78
- return { action: 'replace' } as const;
79
- }
84
+ diff: Effect.fn(function* ({ news, olds, output }) {
85
+ // ⛔ The identity first, before isResolved; onto a role that exists fails the plan.
86
+ const move = yield* judgeRename(IDENTITY, olds, news, output);
87
+ if (output === undefined) return undefined;
88
+ if (move !== undefined) return { action: 'replace' } as const;
89
+ if (!isResolved(news)) return undefined;
80
90
  return { action: yield* planRole(kubernetesRoleSpec(news)) } as const;
81
91
  }),
82
92
 
83
- reconcile: Effect.fn(function* ({ news }) {
93
+ reconcile: Effect.fn(function* ({ news, output }) {
94
+ // ⛔ An `update` across a move the diff could not see — refused before any write.
95
+ yield* guardRename(IDENTITY, news, output);
84
96
  return yield* reconcileRole(kubernetesRoleSpec(news));
85
97
  }),
86
98
 
@@ -19,9 +19,12 @@
19
19
  * default `retain` it never runs; with `destroy`, set `retain` to drop it from state and leave it
20
20
  * live — which is what the server would do anyway.
21
21
  *
22
- * ★ REPLACE SEMANTICS (REPLACE.md): `name` changed → `replace`, create-first (names are per
23
- * namespace; the new one cannot collide). Under `retain` BOTH enforcements then apply — fail
24
- * closed. Under `destroy` the old one's delete is refused, so the deploy stops after creating.
22
+ * ★ REPLACE SEMANTICS (REPLACE.md): `name` changed → `replace`, create-first. Under `retain` BOTH
23
+ * enforcements then apply — fail closed. Under `destroy` the old one's delete is refused, so the
24
+ * deploy stops after creating. ⛔ A rename onto an enforcement that exists fails the plan
25
+ * (rename-identity.ts): the upsert by name would otherwise rewrite, in place, what someone else's
26
+ * enforcement demands. MEASURED 2026-09-21: a swap of two under `destroy` wrote each over the
27
+ * other, then stopped on the two refused deletes.
25
28
  */
26
29
  import { Resource } from 'alchemy';
27
30
  import { isResolved } from 'alchemy/Diff';
@@ -38,7 +41,8 @@ import {
38
41
  problems,
39
42
  writeBody,
40
43
  } from './mfa-enforcement-form.ts';
41
- import { authAccessors, readEnforcement, writeEnforcement } from './mfa-wire.ts';
44
+ import { authAccessors, enforcementPath, readEnforcement, writeEnforcement } from './mfa-wire.ts';
45
+ import { guardRename, judgeRename, nameIdentity } from './rename-identity.ts';
42
46
 
43
47
  export type {
44
48
  BaoMfaLoginEnforcementAttributes,
@@ -59,6 +63,12 @@ export const BaoMfaLoginEnforcement = Resource<BaoMfaLoginEnforcement>('Bao.MfaL
59
63
 
60
64
  type Env = HttpClient.HttpClient;
61
65
 
66
+ /** Exact: the identity store indexes enforcements by (namespace, name) as given. */
67
+ const IDENTITY = nameIdentity<BaoMfaLoginEnforcementAttributes>(
68
+ 'Bao.MfaLoginEnforcement',
69
+ enforcementPath,
70
+ );
71
+
62
72
  const refuse = (name: string, message: string): Effect.Effect<never> =>
63
73
  Effect.die(new Error(`Bao.MfaLoginEnforcement ${name}: ${message}`));
64
74
 
@@ -119,13 +129,18 @@ export const BaoMfaLoginEnforcementProvider = () =>
119
129
  }),
120
130
 
121
131
  /** ⛔ IT COMPARES THE LIVE ENFORCEMENT — a target removed by hand is drift, not a noop. */
122
- diff: Effect.fn(function* ({ news, output }) {
123
- if (output === undefined || !isResolved(news)) return undefined;
124
- if (news.name !== output.name) return { action: 'replace' } as const;
132
+ diff: Effect.fn(function* ({ news, olds, output }) {
133
+ // ⛔ The name first, before isResolved; onto an enforcement that exists fails the plan.
134
+ const move = yield* judgeRename(IDENTITY, olds, news, output);
135
+ if (output === undefined) return undefined;
136
+ if (move !== undefined) return { action: 'replace' } as const;
137
+ if (!isResolved(news)) return undefined;
125
138
  return { action: yield* planEnforcement(news) } as const;
126
139
  }),
127
140
 
128
- reconcile: Effect.fn(function* ({ news }) {
141
+ reconcile: Effect.fn(function* ({ news, output }) {
142
+ // ⛔ An `update` across a rename the diff could not see — refused before any write.
143
+ yield* guardRename(IDENTITY, news, output);
129
144
  return yield* reconcileEnforcement(news);
130
145
  }),
131
146
 
@@ -31,6 +31,7 @@ import {
31
31
  writeBody,
32
32
  } from './mfa-totp-form.ts';
33
33
  import { deleteTotp, findTotp, writeTotp } from './mfa-wire.ts';
34
+ import { declaredString } from './rename.ts';
34
35
 
35
36
  export type {
36
37
  BaoMfaTotpMethodAttributes,
@@ -50,8 +51,8 @@ export const BaoMfaTotpMethod = Resource<BaoMfaTotpMethod>('Bao.MfaTotpMethod',
50
51
  defaultRemovalPolicy: 'retain',
51
52
  });
52
53
 
53
- const refuse = (props: BaoMfaTotpMethodProps, message: string): Effect.Effect<never> =>
54
- Effect.die(new Error(`Bao.MfaTotpMethod ${props.name}: ${message}`));
54
+ const refuse = (name: string, message: string): Effect.Effect<never> =>
55
+ Effect.die(new Error(`Bao.MfaTotpMethod ${name}: ${message}`));
55
56
 
56
57
  export const readTotp = (
57
58
  props: BaoMfaTotpMethodProps,
@@ -75,13 +76,13 @@ export const reconcileTotp = (
75
76
  ): Effect.Effect<BaoMfaTotpMethodAttributes, BaoError, HttpClient.HttpClient> =>
76
77
  Effect.gen(function* () {
77
78
  const bad = problems(props);
78
- if (bad.length > 0) return yield* refuse(props, bad.join('; '));
79
+ if (bad.length > 0) return yield* refuse(props.name, bad.join('; '));
79
80
  const live = yield* readTotp(props);
80
81
  if (live === undefined || !matches(live, props)) yield* writeTotp(writeBody(props));
81
82
  const after = yield* readTotp(props);
82
83
  if (after === undefined)
83
- return yield* refuse(props, 'the write succeeded but no method has that name.');
84
- if (!matches(after, props)) return yield* refuse(props, 'the method read back different.');
84
+ return yield* refuse(props.name, 'the write succeeded but no method has that name.');
85
+ if (!matches(after, props)) return yield* refuse(props.name, 'the method read back different.');
85
86
  return after;
86
87
  });
87
88
 
@@ -97,14 +98,26 @@ export const BaoMfaTotpMethodProvider = () =>
97
98
  return yield* readTotp(olds);
98
99
  }),
99
100
 
101
+ /**
102
+ * ⛔ THE NAME IS JUDGED BEFORE `isResolved(news)`. Until 2026-09-21 it came after, so a
103
+ * rename landing with a pending `issuer` (or any other Output) planned `update`, and the
104
+ * upsert by name wrote a SECOND method: the header's stranding, reached through a green
105
+ * plan. MEASURED through the engine (rename-families.test.ts).
106
+ */
100
107
  diff: Effect.fn(function* ({ news, output }) {
108
+ const name = declaredString(news, 'name');
109
+ if (output !== undefined && name !== undefined) {
110
+ const rename = renameProblem(output.name, name);
111
+ if (rename !== undefined) return yield* refuse(name, rename);
112
+ }
101
113
  if (output === undefined || !isResolved(news)) return undefined;
102
- const rename = renameProblem(output.name, news.name);
103
- if (rename !== undefined) return yield* refuse(news, rename);
104
114
  return { action: yield* planTotp(news) } as const;
105
115
  }),
106
116
 
107
- reconcile: Effect.fn(function* ({ news }) {
117
+ /** ⛔ A rename the diff could not see (the name was an Output), refused before any write. */
118
+ reconcile: Effect.fn(function* ({ news, output }) {
119
+ const rename = output === undefined ? undefined : renameProblem(output.name, news.name);
120
+ if (rename !== undefined) return yield* refuse(news.name, rename);
108
121
  return yield* reconcileTotp(news);
109
122
  }),
110
123
 
@@ -44,7 +44,6 @@ 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
46
  import { baoDelete, baoRead, baoWrite } from './bao-http.ts';
47
- import { mountPath } from './mount-form.ts';
48
47
  import {
49
48
  type BaoPkiRoleAttributes,
50
49
  type BaoPkiRoleProps,
@@ -54,6 +53,7 @@ import {
54
53
  rolePath,
55
54
  writeBody,
56
55
  } from './pki-role-form.ts';
56
+ import { guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
57
57
 
58
58
  export type { BaoPkiRoleAttributes, BaoPkiRoleProps };
59
59
 
@@ -69,6 +69,13 @@ export const BaoPkiRole = Resource<BaoPkiRole>('Bao.PkiRole', {
69
69
  defaultRemovalPolicy: 'retain',
70
70
  });
71
71
 
72
+ /** Exact: the engine stores `role/<name>` verbatim (builtin/logical/pki/path_roles.go:963). */
73
+ const IDENTITY = roleIdentity<BaoPkiRoleAttributes>(
74
+ 'Bao.PkiRole',
75
+ (mount, name) => rolePath({ mount, name }),
76
+ 'pki',
77
+ );
78
+
72
79
  const readRole = (props: BaoPkiRoleProps) =>
73
80
  Effect.gen(function* () {
74
81
  const live = yield* baoRead(rolePath(props));
@@ -100,18 +107,21 @@ export const BaoPkiRoleProvider = () =>
100
107
  * fields is also what stops a field being added to props and quietly forgotten in
101
108
  * the comparison.
102
109
  */
103
- diff: Effect.fn(function* ({ news, output }) {
104
- if (output === undefined || !isResolved(news)) return undefined;
110
+ diff: Effect.fn(function* ({ news, olds, output }) {
105
111
  /**
106
112
  * ⚠️ A ROLE IS IDENTIFIED BY MOUNT **AND** NAME. Moving a declaration to another
107
113
  * engine is a different object under a different CA, never an in-place edit —
108
- * `replace`, so Alchemy creates the new one and retires the old.
114
+ * `replace`, so Alchemy creates the new one and retires the old. Judged before
115
+ * `isResolved(news)`, and ⛔ a move onto a role that exists fails the plan
116
+ * (rename-identity.ts).
109
117
  * ⛔ A RENAME WAS NOT CAUGHT UNTIL 2026-09-21 (REPLACE.md): only the mount was compared,
110
118
  * so a new `name` read nothing at the new path, planned `update`, wrote the new role,
111
119
  * and left the old one issuing certificates under no state record at all.
112
120
  */
113
- if (mountPath(news.mount ?? 'pki') !== output.mount || news.name !== output.name)
114
- return { action: 'replace' } as const;
121
+ const move = yield* judgeRename(IDENTITY, olds, news, output);
122
+ if (output === undefined) return undefined;
123
+ if (move !== undefined) return { action: 'replace' } as const;
124
+ if (!isResolved(news)) return undefined;
115
125
  /**
116
126
  * ⚠️ A DECLARATION reconcile WOULD REFUSE MUST NEVER PLAN AS noop. An empty
117
127
  * allowedDomains, or a duration OpenBao cannot parse, can compare equal to a
@@ -127,8 +137,10 @@ export const BaoPkiRoleProvider = () =>
127
137
  : ({ action: 'update' } as const);
128
138
  }),
129
139
 
130
- reconcile: Effect.fn(function* ({ news }) {
140
+ reconcile: Effect.fn(function* ({ news, output }) {
131
141
  const path = rolePath(news);
142
+ // ⛔ An `update` across a move the diff could not see — refused before any write.
143
+ yield* guardRename(IDENTITY, news, output);
132
144
  /**
133
145
  * ⛔ AN EMPTY allowedDomains IS A REFUSAL, NOT AN EMPTY ROLE. `allow_any_name` is
134
146
  * false on every live role, so a role with no allowed domains can issue NOTHING —
@@ -24,9 +24,18 @@ 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 { type BaoPluginAttributes, type BaoPluginProps, matches, resolve } from './plugin-form.ts';
27
+ import {
28
+ type BaoPluginAttributes,
29
+ type BaoPluginProps,
30
+ type BaoPluginType,
31
+ matches,
32
+ resolve,
33
+ versionedPath,
34
+ } from './plugin-form.ts';
28
35
  import { readEntry, reconcilePlugin } from './plugin-reconcile.ts';
29
36
  import { deregisterPlugin } from './plugin-wire.ts';
37
+ import { declaredOr, declaredString } from './rename.ts';
38
+ import { type Identity, guardRename, judgeRename } from './rename-identity.ts';
30
39
 
31
40
  export type { BaoPluginAttributes, BaoPluginProps, BaoPluginType } from './plugin-form.ts';
32
41
 
@@ -42,6 +51,24 @@ export const BaoPlugin = Resource<BaoPlugin>('Bao.Plugin', {
42
51
  defaultRemovalPolicy: 'retain',
43
52
  });
44
53
 
54
+ /**
55
+ * ⛔ NAME, TYPE AND VERSION ARE THE CATALOG KEY, so the identity is the versioned path a read and a
56
+ * delete address. Exact: the catalog stores `<type>/<name>/<version>` as given, and the version
57
+ * is already canonical (`problems`). ⚠️ An unversioned name that is a builtin reads back as
58
+ * present (plugin-wire.ts), so a move onto one fails the plan, as reconcile would refuse anyway.
59
+ */
60
+ const IDENTITY: Identity<BaoPluginAttributes> = {
61
+ declared: (props) => {
62
+ const type = declaredString(props, 'type') as BaoPluginType | undefined;
63
+ const name = declaredString(props, 'name');
64
+ const version = declaredOr(props, 'version', '');
65
+ if (type === undefined || name === undefined || version === undefined) return undefined;
66
+ return versionedPath(type, name, version);
67
+ },
68
+ family: 'Bao.Plugin',
69
+ recorded: (output) => versionedPath(output.type, output.name, output.version),
70
+ };
71
+
45
72
  export const BaoPluginProvider = () =>
46
73
  Provider.effect(
47
74
  BaoPlugin,
@@ -60,21 +87,18 @@ export const BaoPluginProvider = () =>
60
87
  }),
61
88
 
62
89
  /** ⛔ IT COMPARES THE LIVE ENTRY, NOT THE STORED DIGEST — a hand re-register is drift. */
63
- diff: Effect.fn(function* ({ news, output }) {
64
- if (output === undefined || !isResolved(news)) return undefined;
65
- const form = resolve(news);
90
+ diff: Effect.fn(function* ({ news, olds, output }) {
66
91
  /**
67
- * ⛔ NAME, TYPE AND VERSION ARE THE CATALOG KEY, so changing one is a different entry,
68
- * not an edit. Writing the new key would leave the old registration in place under a
69
- * state record that no longer names it.
92
+ * ⛔ A NEW NAME, TYPE OR VERSION IS A DIFFERENT ENTRY, not an edit: `replace`, judged
93
+ * before `isResolved(news)`. Writing the new key in place would leave the old
94
+ * registration under a state record that no longer names it. ⛔ A move onto an entry
95
+ * that is already registered fails the plan (rename-identity.ts).
70
96
  */
71
- if (
72
- output.name !== form.name ||
73
- output.type !== form.type ||
74
- output.version !== form.version
75
- ) {
76
- return { action: 'replace' } as const;
77
- }
97
+ const move = yield* judgeRename(IDENTITY, olds, news, output);
98
+ if (output === undefined) return undefined;
99
+ if (move !== undefined) return { action: 'replace' } as const;
100
+ if (!isResolved(news)) return undefined;
101
+ const form = resolve(news);
78
102
  const found = yield* readEntry(form);
79
103
  if (found === undefined) return { action: 'update' } as const;
80
104
  return matches(found.attributes, form)
@@ -83,7 +107,9 @@ export const BaoPluginProvider = () =>
83
107
  }),
84
108
 
85
109
  /** The refusals — declarative, builtin, self-reported version — live in plugin-reconcile.ts. */
86
- reconcile: Effect.fn(function* ({ news }) {
110
+ reconcile: Effect.fn(function* ({ news, output }) {
111
+ // ⛔ An `update` across a move the diff could not see — refused before any write.
112
+ yield* guardRename(IDENTITY, news, output);
87
113
  return yield* reconcilePlugin(news);
88
114
  }),
89
115
 
@@ -8,7 +8,8 @@ import type * as HttpClient from 'effect/unstable/http/HttpClient';
8
8
  import { sha256 } from './digest.ts';
9
9
  import { isEmptyAssembly } from './policy-assembly.ts';
10
10
  import { deletePolicy, policyPath, readPolicy, writePolicy } from './policy-wire.ts';
11
- import { declaredString, isMoved, judgeMove, policyKey, refuseMovedUpdate } from './rename.ts';
11
+ import { policyKey } from './rename.ts';
12
+ import { guardRename, judgeRename, nameIdentity } from './rename-identity.ts';
12
13
 
13
14
  /**
14
15
  * An OpenBao ACL policy, assembled from the HCL fragments that declare it.
@@ -99,6 +100,9 @@ export const BaoPolicy = Resource<BaoPolicy>('Bao.Policy', {
99
100
  defaultRemovalPolicy: 'retain',
100
101
  });
101
102
 
103
+ /** A policy is its name, keyed as OpenBao keys it (`policyKey`). */
104
+ const IDENTITY = nameIdentity<BaoPolicyAttributes>('Bao.Policy', policyPath, policyKey);
105
+
102
106
  /** `path "..."` grant count — the same number the apply script prints. */
103
107
  const grantsOf = (hcl: string) => (hcl.match(/^path /gm) ?? []).length;
104
108
 
@@ -156,9 +160,7 @@ export const BaoPolicyProvider = () =>
156
160
  * delete, so a role outside this graph that names the old policy must move in the same
157
161
  * PR (REPLACE.md).
158
162
  */
159
- const tried = output?.name ?? declaredString(olds, 'name');
160
- const declared = declaredString(news, 'name');
161
- const move = yield* judgeMove('Bao.Policy', tried, declared, policyPath, policyKey);
163
+ const move = yield* judgeRename(IDENTITY, olds, news, output);
162
164
  if (output === undefined) return undefined;
163
165
  if (move !== undefined) return { action: 'replace' } as const;
164
166
  // ⚠️ A prop can still be an unresolved Output or Config at plan time. Docker's
@@ -175,9 +177,7 @@ export const BaoPolicyProvider = () =>
175
177
  reconcile: Effect.fn(function* ({ news, output }) {
176
178
  const name = news.name;
177
179
  // ⛔ An `update` across a rename the diff could not see — refused before any write.
178
- if (output !== undefined && isMoved(output.name, name, policyKey) === true) {
179
- return yield* refuseMovedUpdate(`Bao.Policy ${name}`, output.name, name);
180
- }
180
+ yield* guardRename(IDENTITY, news, output);
181
181
  const { joined, parts } = yield* assemble(news.fragments);
182
182
  /**
183
183
  * ⛔ AN EMPTY ASSEMBLY IS A REFUSAL, NOT AN EMPTY POLICY. `bao policy write` with
@@ -50,15 +50,8 @@ import {
50
50
  writeBody,
51
51
  } from './proxmox-role-form.ts';
52
52
  import { readRole } from './proxmox-role-wire.ts';
53
- import {
54
- declaredRolePath,
55
- declaredString,
56
- isMoved,
57
- isPendingProp,
58
- judgeMove,
59
- refuseMovedUpdate,
60
- triedRolePath,
61
- } from './rename.ts';
53
+ import { declaredString, isPendingProp } from './rename.ts';
54
+ import { guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
62
55
 
63
56
  export type { BaoProxmoxRoleAttributes, BaoProxmoxRoleProps };
64
57
 
@@ -74,6 +67,9 @@ export const BaoProxmoxRole = Resource<BaoProxmoxRole>('Bao.ProxmoxRole', {
74
67
  defaultRemovalPolicy: 'retain',
75
68
  });
76
69
 
70
+ /** Exact: the plugin stores `roles/<name>` verbatim. */
71
+ const IDENTITY = roleIdentity<BaoProxmoxRoleAttributes>('Bao.ProxmoxRole', rolePath);
72
+
77
73
  export const BaoProxmoxRoleProvider = () =>
78
74
  Provider.effect(
79
75
  BaoProxmoxRole,
@@ -110,9 +106,7 @@ export const BaoProxmoxRoleProvider = () =>
110
106
  * still an Output it cannot be checked, so the diff defers, and reconcile refuses the
111
107
  * resulting `update`.
112
108
  */
113
- const tried = triedRolePath(output, olds, rolePath);
114
- const declared = declaredRolePath(news, rolePath);
115
- const move = yield* judgeMove('Bao.ProxmoxRole', tried, declared, (path) => path);
109
+ const move = yield* judgeRename(IDENTITY, olds, news, output);
116
110
  if (output === undefined) return undefined;
117
111
  if (move !== undefined) {
118
112
  const mintUser = declaredString(news, 'mintUser');
@@ -151,10 +145,7 @@ export const BaoProxmoxRoleProvider = () =>
151
145
  reconcile: Effect.fn(function* ({ news, output }) {
152
146
  const path = rolePath(news.mount, news.name);
153
147
  // ⛔ An `update` across a move the diff could not see — refused before any read or write.
154
- const before = output === undefined ? path : rolePath(output.mount, output.name);
155
- if (isMoved(before, path) === true) {
156
- return yield* refuseMovedUpdate('Bao.ProxmoxRole', before, path);
157
- }
148
+ yield* guardRename(IDENTITY, news, output);
158
149
  const live = yield* readRole(news);
159
150
  /**
160
151
  * ⛔ A mint_user CHANGE RE-SCOPES EVERY CREDENTIAL THE ROLE WILL EVER MINT, SILENTLY.
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The swap the plan-time guard cannot see: a NEW declaration of a name that is live. 🔴 MEASURED
3
+ * 2026-09-21 through Alchemy's own plan and apply (fake-stack.ts). With no state row, the engine asks
4
+ * `read`, which finds the object and answers plain attributes, so Alchemy silently adopts it
5
+ * (alchemy@2.0.0-beta.79 Plan.ts, the adoption probe; AdoptPolicy.ts). Nothing moved, so
6
+ * `judgeMove` never runs. Under `RemovalPolicy.destroy()` the old owner's delete then runs in
7
+ * Apply's phase 2, after the adoption has written, and removes the object the new declaration now
8
+ * claims. The deploy is green.
9
+ *
10
+ * ★ These pin that, and the two ways round it REPLACE.md gives: `renamedFrom` for a logical id that
11
+ * changes, and a second deploy for a name another resource is leaving. If one of the hazards stops
12
+ * reproducing, the engine changed: re-read REPLACE.md before relaxing it.
13
+ *
14
+ * ⛔ TEST-ONLY. Every value is a placeholder, not the estate's.
15
+ */
16
+ import assert from 'node:assert/strict';
17
+ import { describe, it } from 'node:test';
18
+ import { renamedFrom } from 'alchemy';
19
+ import * as RemovalPolicy from 'alchemy/RemovalPolicy';
20
+ import * as Effect from 'effect/Effect';
21
+ import { FAMILIES, rowOf, withEstate } from './fake-families.ts';
22
+ import { writesOf } from './fake-stack.ts';
23
+
24
+ const { destroy } = RemovalPolicy;
25
+
26
+ /** No delete endpoint (jwt-config.ts), and a delete the provider refuses (mfa-enforcement.ts). */
27
+ const KEEPS = new Set(['Bao.JwtAuthConfig', 'Bao.MfaLoginEnforcement']);
28
+ const REFUSES_DELETE = /Bao\.MfaLoginEnforcement/;
29
+
30
+ describe('a logical id changed without renamedFrom, under destroy', () => {
31
+ for (const row of FAMILIES) {
32
+ it(`${row.family}: the orphan delete removes what the new id just adopted`, async () => {
33
+ await withEstate(async (stack, estate) => {
34
+ await stack.deploy(row.declare('X', 'a', '15m', destroy));
35
+ const again = stack.deploy(row.declare('Z', 'a', '15m', destroy));
36
+ if (row.family === 'Bao.MfaLoginEnforcement') await assert.rejects(again, REFUSES_DELETE);
37
+ else assert.deepEqual(await again, { X: 'delete', Z: 'adopted' });
38
+ assert.equal(row.has(estate, 'a'), KEEPS.has(row.family));
39
+ });
40
+ });
41
+ }
42
+ });
43
+
44
+ describe('the ways round it', () => {
45
+ const row = rowOf('Bao.AuthRole');
46
+
47
+ it('renamedFrom migrates the state row instead: an update that deletes nothing', async () => {
48
+ await withEstate(async (stack, estate, seen) => {
49
+ await stack.deploy(row.declare('X', 'a', '15m', destroy));
50
+ seen.length = 0;
51
+ const moved = row.declare('Z', 'a', '15m', destroy).pipe(renamedFrom('X'));
52
+ assert.deepEqual(await stack.deploy(moved), { Z: 'update' });
53
+ assert.deepEqual(writesOf(seen), []);
54
+ assert.ok(row.has(estate, 'a'));
55
+ });
56
+ });
57
+
58
+ it('a name another resource moves off is lost when taken in the same deploy', async () => {
59
+ await withEstate(async (stack, estate) => {
60
+ await stack.deploy(row.declare('X', 'a', '15m', destroy));
61
+ const both = Effect.gen(function* () {
62
+ yield* row.declare('X', 'b', '15m', destroy);
63
+ yield* row.declare('Z', 'a', '30m', destroy);
64
+ });
65
+ assert.deepEqual(await stack.deploy(both), { X: 'replace', Z: 'adopted' });
66
+ assert.ok(row.has(estate, 'b') && !row.has(estate, 'a'));
67
+ });
68
+ });
69
+
70
+ it('and survives when taken in the deploy after the move', async () => {
71
+ await withEstate(async (stack, estate) => {
72
+ await stack.deploy(row.declare('X', 'a', '15m', destroy));
73
+ await stack.deploy(row.declare('X', 'b', '15m', destroy));
74
+ const both = Effect.gen(function* () {
75
+ yield* row.declare('X', 'b', '15m', destroy);
76
+ yield* row.declare('Z', 'a', '30m', destroy);
77
+ });
78
+ assert.deepEqual(await stack.deploy(both), { X: 'noop', Z: 'create' });
79
+ assert.ok(row.has(estate, 'a') && row.has(estate, 'b'));
80
+ });
81
+ });
82
+ });