@homeflare/alchemy 0.41.0 → 0.42.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 (38) hide show
  1. package/dist/proxmox/acl.d.ts.map +1 -1
  2. package/dist/proxmox/api-token.d.ts.map +1 -1
  3. package/dist/proxmox/credential-errors.d.ts +18 -4
  4. package/dist/proxmox/credential-errors.d.ts.map +1 -1
  5. package/dist/proxmox/credentials.d.ts +35 -1
  6. package/dist/proxmox/credentials.d.ts.map +1 -1
  7. package/dist/proxmox/group.d.ts.map +1 -1
  8. package/dist/proxmox/index.js +34 -18
  9. package/dist/proxmox/index.js.map +16 -15
  10. package/dist/proxmox/lease-cache.d.ts +67 -5
  11. package/dist/proxmox/lease-cache.d.ts.map +1 -1
  12. package/dist/proxmox/mint.d.ts +4 -2
  13. package/dist/proxmox/mint.d.ts.map +1 -1
  14. package/dist/proxmox/node-network.d.ts.map +1 -1
  15. package/dist/proxmox/role.d.ts.map +1 -1
  16. package/dist/proxmox/storage.d.ts.map +1 -1
  17. package/dist/proxmox/unreadable-read.d.ts +8 -1
  18. package/dist/proxmox/unreadable-read.d.ts.map +1 -1
  19. package/dist/proxmox/user.d.ts.map +1 -1
  20. package/dist/proxmox/zfs-pool.d.ts.map +1 -1
  21. package/docs/credentials.md +14 -1
  22. package/package.json +1 -1
  23. package/src/proxmox/acl.ts +7 -1
  24. package/src/proxmox/api-token.ts +7 -1
  25. package/src/proxmox/credential-errors.ts +19 -5
  26. package/src/proxmox/credentials.test.ts +73 -1
  27. package/src/proxmox/credentials.ts +72 -1
  28. package/src/proxmox/group.ts +7 -1
  29. package/src/proxmox/lease-cache-mint.test.ts +70 -0
  30. package/src/proxmox/lease-cache.test.ts +83 -7
  31. package/src/proxmox/lease-cache.ts +74 -10
  32. package/src/proxmox/mint.ts +12 -5
  33. package/src/proxmox/node-network.ts +7 -1
  34. package/src/proxmox/role.ts +7 -1
  35. package/src/proxmox/storage.ts +7 -1
  36. package/src/proxmox/unreadable-read.ts +16 -4
  37. package/src/proxmox/user.ts +7 -1
  38. package/src/proxmox/zfs-pool.ts +7 -1
@@ -14,13 +14,13 @@ import * as Cache from 'effect/Cache';
14
14
  import * as Duration from 'effect/Duration';
15
15
  import * as Effect from 'effect/Effect';
16
16
  import * as Exit from 'effect/Exit';
17
- import type { PveCredential } from './credentials.ts';
18
- import { type LeaseKey, makeLeases, timeToLive } from './lease-cache.ts';
17
+ import { type PveCredential, type PveTarget, mintTier } from './credentials.ts';
18
+ import { type LeaseKey, keyOf, makeLeases, targetForKey, timeToLive } from './lease-cache.ts';
19
19
 
20
20
  const C1: LeaseKey = {
21
21
  mount: 'proxmox-c1',
22
- role: 'read',
23
22
  scheme: 'pve',
23
+ tier: 'read',
24
24
  };
25
25
  /** ⚠️ Same role as C1, DIFFERENT mount — the collision the key has to survive. */
26
26
  const C2: LeaseKey = { ...C1, mount: 'proxmox-c2' };
@@ -79,6 +79,61 @@ describe('how long a lease is kept', () => {
79
79
  });
80
80
  });
81
81
 
82
+ describe('K-T1: keyOf resolves an overridden tier into the cache key (I1)', () => {
83
+ // ⛔ The one line the PR exists for (`tier: mintTier(target, role)` in `keyOf`) had no direct
84
+ // test: revert it to `tier: role` and the rest of the suite still passes, because
85
+ // `targetForKey`'s tests prove only the reconstruction half and `mintTier`'s tests prove only
86
+ // the resolution function, never the composition a real `leased()` call depends on.
87
+ it('a plain PveTarget with no override keys on the bare role', () => {
88
+ const target: PveTarget = { members: [], mount: 'proxmox-tb4', scheme: 'pve' };
89
+ assert.equal(keyOf(target, 'read').tier, 'read');
90
+ });
91
+
92
+ it('a PveTarget.roles override reaches the cache key, not just a direct mint', () => {
93
+ const target: PveTarget = {
94
+ members: [],
95
+ mount: 'proxmox-tb4',
96
+ roles: { read: 'talos-provision' },
97
+ scheme: 'pve',
98
+ };
99
+ assert.equal(keyOf(target, 'read').tier, 'talos-provision');
100
+ // The unmentioned role still falls back to itself on the same target.
101
+ assert.equal(keyOf(target, 'provision').tier, 'provision');
102
+ });
103
+
104
+ it('a PbsTarget key never carries an override — the type has no roles field', () => {
105
+ const pbs = { api: 'https://pbs.invalid/api2/json', mount: 'pbs-c1', scheme: 'pbs' as const };
106
+ assert.equal(keyOf(pbs, 'read').tier, 'read');
107
+ });
108
+ });
109
+
110
+ describe('K-T1/M3: targetForKey round-trips the resolved tier back through mintTier', () => {
111
+ // ⛔ Since M3 dropped `role` from `LeaseKey`, `targetForKey` always mints a pve reconstruction
112
+ // with the fixed role `'read'` — the caller's ORIGINAL role is irrelevant here, only `tier`
113
+ // (already resolved by `keyOf`) has to round-trip.
114
+ it('a plain tier reconstructs to itself', () => {
115
+ const key: LeaseKey = { mount: 'proxmox-tb4', scheme: 'pve', tier: 'read' };
116
+ assert.equal(mintTier(targetForKey(key), 'read'), 'read');
117
+ });
118
+
119
+ it('an overridden tier reconstructs to the SAME tier, not the bare role', () => {
120
+ // ⛔ This is the exact reconstruction the cache-miss path depends on: `keyOf` resolved
121
+ // `talos-provision` from the caller's real target once; `targetForKey` never sees that
122
+ // target again, only this key, so it has to be able to rebuild the same answer from `tier`
123
+ // alone.
124
+ const key: LeaseKey = { mount: 'proxmox-tb4', scheme: 'pve', tier: 'talos-provision' };
125
+ assert.equal(mintTier(targetForKey(key), 'read'), 'talos-provision');
126
+ });
127
+
128
+ it('a pbs key never carries an override through', () => {
129
+ const key: LeaseKey = { mount: 'pbs-c1', scheme: 'pbs', tier: 'provision' };
130
+ assert.equal(mintTier(targetForKey(key), 'provision'), 'provision');
131
+ });
132
+ });
133
+
134
+ // ⚠️ `mintForKey`'s own request-path behavior is exercised in lease-cache-mint.test.ts, against a
135
+ // fake OpenBao server — not hand-reproduced here.
136
+
82
137
  describe('lease cache under concurrency', () => {
83
138
  it('mints once for many concurrent callers, and serves the next from the cache', async () => {
84
139
  const mint = countingMint(() => cred('hf-read@pve!a', 3600));
@@ -123,7 +178,7 @@ describe('lease cache under concurrency', () => {
123
178
  assert.equal(mint.calls(), 2);
124
179
  });
125
180
 
126
- it('does not serve one cluster the credential of another with the same mount and role', async () => {
181
+ it('does not serve one cluster the credential of another with the same tier', async () => {
127
182
  const mint = countingMint((call) => cred(`hf-read@pve!${String(call)}`, 3600));
128
183
  const [c1, c2] = await Effect.runPromise(
129
184
  Effect.gen(function* () {
@@ -137,8 +192,8 @@ describe('lease cache under concurrency', () => {
137
192
 
138
193
  it('shares one lease across two members of the same cluster mount', async () => {
139
194
  const mint = countingMint((call) => cred(`hf-read@pve!${String(call)}`, 3600));
140
- const NODE_B: LeaseKey = { mount: 'proxmox-c1', role: 'read', scheme: 'pve' };
141
- const NODE_C: LeaseKey = { mount: 'proxmox-c1', role: 'read', scheme: 'pve' };
195
+ const NODE_B: LeaseKey = { mount: 'proxmox-c1', scheme: 'pve', tier: 'read' };
196
+ const NODE_C: LeaseKey = { mount: 'proxmox-c1', scheme: 'pve', tier: 'read' };
142
197
  await Effect.runPromise(
143
198
  Effect.gen(function* () {
144
199
  const leases = yield* makeLeases(mint.mintFor);
@@ -151,7 +206,7 @@ describe('lease cache under concurrency', () => {
151
206
 
152
207
  it('keeps read and provision apart', async () => {
153
208
  const mint = countingMint((call) => cred(`hf@pve!${String(call)}`, 3600));
154
- const PROVISION: LeaseKey = { ...C1, role: 'provision' };
209
+ const PROVISION: LeaseKey = { ...C1, tier: 'provision' };
155
210
  const [read, provision] = await Effect.runPromise(
156
211
  Effect.gen(function* () {
157
212
  const leases = yield* makeLeases(mint.mintFor);
@@ -160,4 +215,25 @@ describe('lease cache under concurrency', () => {
160
215
  );
161
216
  assert.notEqual(read.tokenId, provision.tokenId);
162
217
  });
218
+
219
+ // ⛔ K-T1's own reason `tier` joined the key: two consumers on the SAME mount/scheme but
220
+ // DIFFERENT `PveTarget.roles` overrides would otherwise collide into one cache entry and hand
221
+ // each other's credential to the wrong caller. M3 dropped `role` from the key entirely (it
222
+ // was always redundant with `tier`), which this test still exercises unchanged.
223
+ it('keeps two different tier overrides of the same mount/scheme apart', async () => {
224
+ const mint = countingMint((call) => cred(`hf@pve!${String(call)}`, 3600));
225
+ const DEFAULT_READ: LeaseKey = { mount: 'proxmox-tb4', scheme: 'pve', tier: 'read' };
226
+ const OVERRIDDEN_READ: LeaseKey = { ...DEFAULT_READ, tier: 'talos-provision' };
227
+ const [plain, overridden] = await Effect.runPromise(
228
+ Effect.gen(function* () {
229
+ const leases = yield* makeLeases(mint.mintFor);
230
+ return [
231
+ yield* Cache.get(leases, DEFAULT_READ),
232
+ yield* Cache.get(leases, OVERRIDDEN_READ),
233
+ ] as const;
234
+ }),
235
+ );
236
+ assert.notEqual(plain.tokenId, overridden.tokenId);
237
+ assert.equal(mint.calls(), 2);
238
+ });
163
239
  });
@@ -47,7 +47,13 @@ import * as Cache from 'effect/Cache';
47
47
  import * as Duration from 'effect/Duration';
48
48
  import * as Effect from 'effect/Effect';
49
49
  import * as Exit from 'effect/Exit';
50
- import { type ApiTarget, type PveCredential, type PveRole } from './credentials.ts';
50
+ import {
51
+ type ApiTarget,
52
+ type BaoEnvironment,
53
+ type PveCredential,
54
+ type PveRole,
55
+ mintTier,
56
+ } from './credentials.ts';
51
57
  import { mint } from './mint.ts';
52
58
 
53
59
  /**
@@ -66,24 +72,49 @@ const REMINT_MARGIN_SECONDS = 60;
66
72
  /**
67
73
  * What a credential is cached under.
68
74
  *
69
- * ⛔ KEYED ON THE OPENBAO MOUNT AND ROLE, NOT ON A MEMBER HOSTNAME. A PVE API token is
70
- * cluster-wide — `user.cfg` is replicated — so node-b, node-c and node-d share one credential. Keying on
71
- * `api` would mint three identical tokens when failover rotates members.
75
+ * ⛔ KEYED ON THE OPENBAO MOUNT AND TIER, NOT ON A MEMBER HOSTNAME OR ON `role` (M3, red team on
76
+ * K-T1). A PVE API token is cluster-wide — `user.cfg` is replicated — so node-b, node-c and
77
+ * node-d share one credential. Keying on `api` would mint three identical tokens when failover
78
+ * rotates members. `role` USED to be part of this key too, but the minted credential depends
79
+ * only on `(mount, scheme, tier)` — `role` only ever fed `mintTier` to re-derive the tier the
80
+ * key already carries — so `roles: { read: 'x', provision: 'x' }` (one tier for both roles, the
81
+ * shape `docs/credentials.md` itself recommends) used to open TWO cache entries and mint TWO
82
+ * byte-for-byte interchangeable tokens for what is provably one credential. Dropping `role`
83
+ * collapses that back to one entry without changing the default case, where `tier === role`
84
+ * already.
72
85
  * ★ THE MOUNT CANNOT COLLIDE ACROSS CLUSTERS: `proxmox-c1`, `proxmox-c2` and `pbs-c1` are
73
86
  * distinct OpenBao mounts with distinct policies; two estates never share a mount name.
74
87
  * ★ A PLAIN OBJECT IS A SAFE KEY BECAUSE EFFECT COMPARES KEYS STRUCTURALLY — two separately built
75
88
  * literals with the same fields are one entry, which the probe above measured.
89
+ *
90
+ * ⛔ `tier` IS THE KEY, NOT `role` (K-T1). `targetForKey` below has to RECONSTRUCT a target from
91
+ * the key alone (it does not keep the caller's original `ApiTarget` around), and a
92
+ * `PveTarget.roles` override lives on THAT original target, not on `role` — so without `tier`
93
+ * in the key, every `leased()` call for an overridden role would resolve its override once at
94
+ * the first `keyOf`, then reuse whatever `targetForKey` happens to reconstruct on a cache miss,
95
+ * and TWO DIFFERENT overrides sharing one `{mount, scheme}` (e.g. one consumer's default `read`
96
+ * tier and another's `roles: {read: 'x'}` override, same mount) would collide into ONE cache
97
+ * entry and hand each other's credential to the wrong caller. Resolving the tier at `keyOf` —
98
+ * where the real target is still in hand — and keying on it closes that: two different tiers
99
+ * are two different entries, always.
76
100
  */
77
101
  export type LeaseKey = {
78
102
  readonly mount: string;
79
- readonly role: PveRole;
80
103
  readonly scheme: ApiTarget['scheme'];
104
+ readonly tier: string;
81
105
  };
82
106
 
83
- const keyOf = (target: ApiTarget, role: PveRole): LeaseKey => ({
107
+ /**
108
+ * ⚠️ EXPORTED FOR THE TEST (I1, red team on K-T1). This is the one line the whole PR exists
109
+ * for — `tier: mintTier(target, role)` is what makes an overridden `PveTarget.roles` reach the
110
+ * cached path at all. Nothing else in this file's test suite calls `keyOf` or `leased`
111
+ * directly, so without this export a regression here (e.g. reverting to `tier: role`) passed
112
+ * the whole suite silently.
113
+ */
114
+ export const keyOf = (target: ApiTarget, role: PveRole): LeaseKey => ({
84
115
  mount: target.mount,
85
- role,
86
116
  scheme: target.scheme,
117
+ tier: mintTier(target, role),
87
118
  });
88
119
 
89
120
  /**
@@ -115,12 +146,45 @@ export const timeToLive = (exit: Exit.Exit<PveCredential, unknown>): Duration.Du
115
146
  export const makeLeases = <E, R>(mintFor: (key: LeaseKey) => Effect.Effect<PveCredential, E, R>) =>
116
147
  Cache.makeWith(mintFor, { capacity: 64, requireServicesAt: 'lookup', timeToLive });
117
148
 
118
- const targetForKey = (key: LeaseKey): ApiTarget =>
149
+ /**
150
+ * ⛔ CARRIES `key.tier` BACK IN AS A `roles` OVERRIDE (K-T1), SO `mint`'S OWN `mintTier` CALL
151
+ * REPRODUCES THE SAME TIER `keyOf` ALREADY RESOLVED. This target is synthetic — it is never the
152
+ * caller's real `PveTarget` (`members`/`api` are throwaway placeholders `mint` never reads) —
153
+ * and, since M3 dropped `role` from `LeaseKey`, it no longer even knows which semantic role
154
+ * produced `key.tier`. It doesn't need to: `roles: { read: key.tier }` plus always minting with
155
+ * the fixed role `'read'` (below) makes `mintTier(targetForKey(key), 'read') === key.tier`
156
+ * unconditionally, because `mintTier` only ever consults the ONE role it's called with. Which
157
+ * role the ORIGINAL caller asked for is irrelevant here — `keyOf` already folded that into
158
+ * `key.tier` before this function ever sees the key.
159
+ * ⚠️ THE PBS BRANCH MINTS WITH `key.tier` ITSELF AS THE ROLE, NOT `'read'`. A `PbsTarget` has no
160
+ * `roles` override (see `credentials.ts`'s ⛔ on `PbsTarget`), so `mintTier` for `scheme:'pbs'`
161
+ * always returns its `role` argument unchanged — meaning `key.tier` for a PBS key IS the literal
162
+ * role name (`'read'`/`'provision'`) `keyOf` recorded. Passing anything else here would ask
163
+ * OpenBao for the wrong tier.
164
+ * ⚠️ EXPORTED FOR THE TEST, which checks that round trip directly rather than only through the
165
+ * cache's externally visible behaviour.
166
+ */
167
+ export const targetForKey = (key: LeaseKey): ApiTarget =>
119
168
  key.scheme === 'pbs'
120
169
  ? { api: 'https://pbs.invalid/api2/json', mount: key.mount, scheme: 'pbs' }
121
- : { members: [], mount: key.mount, scheme: 'pve' };
170
+ : { members: [], mount: key.mount, roles: { read: key.tier }, scheme: 'pve' };
171
+
172
+ /**
173
+ * ⚠️ EXPORTED FOR THE TEST (second red-team pass on K-T1). `targetForKey`'s own tests call
174
+ * `mintTier(targetForKey(key), 'read')` to check the round trip, which hand-reproduces this
175
+ * function's fixed choice of `'read'` as the pve mint role rather than exercising it — change
176
+ * that choice here and those tests keep passing while a live cache miss mints the wrong tier.
177
+ * Testing this function directly (against a fake OpenBao) pins the choice itself.
178
+ *
179
+ * ⚠️ `env` DEFAULTS TO `process.env`, SAME AS `mint` ITSELF (credentials.ts) — the cache's real
180
+ * caller below (`Effect.runSync(makeLeases(mintForKey))`) always calls this with one argument,
181
+ * so the default is what production runs on. The parameter exists only so the test can pass a
182
+ * fake OpenBao's address without touching the real environment.
183
+ */
184
+ export const mintForKey = (key: LeaseKey, env: BaoEnvironment = process.env) =>
185
+ mint(targetForKey(key), key.scheme === 'pbs' ? (key.tier as PveRole) : 'read', env);
122
186
 
123
- const leases = Effect.runSync(makeLeases((key) => mint(targetForKey(key), key.role)));
187
+ const leases = Effect.runSync(makeLeases(mintForKey));
124
188
 
125
189
  /**
126
190
  * A credential for `role` on `target`: a still-valid lease when one is kept, one shared mint when
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Minting one PVE/PBS credential from OpenBao: `GET /v1/<mount>/creds/<role>`, alive for five
2
+ * Minting one PVE/PBS credential from OpenBao: `GET /v1/<mount>/creds/<tier>`, alive for five
3
3
  * minutes. Split out of `credentials.ts` (2026-09-24) to keep that file's TYPES under the
4
4
  * 250-line cap once `PveCredentialDenied`'s catchers needed a real header there; the reasoning
5
5
  * for WHY the estate mints rather than stores a token is still credentials.ts's own header.
@@ -14,6 +14,7 @@ import * as Headers from 'effect/unstable/http/Headers';
14
14
  import * as HttpClient from 'effect/unstable/http/HttpClient';
15
15
  import * as HttpClientRequest from 'effect/unstable/http/HttpClientRequest';
16
16
  import { PveCredentialDenied } from './credential-errors.ts';
17
+ import { mintTier } from './credentials.ts';
17
18
  import type { ApiTarget, BaoEnvironment, PveCredential, PveRole } from './credentials.ts';
18
19
 
19
20
  /** openbao v2.6.2 api/env.go:22-34 — a present BAO_* wins, even empty; else its VAULT_* twin. */
@@ -21,7 +22,9 @@ const baoVariable = (env: BaoEnvironment, name: string) =>
21
22
  env[`BAO_${name}`] ?? env[`VAULT_${name}`] ?? '';
22
23
 
23
24
  /**
24
- * Mint one credential for `role`: `GET /v1/<mount>/creds/<role>` on OpenBao's HTTP API.
25
+ * Mint one credential for `role`: `GET /v1/<mount>/creds/<tier>` on OpenBao's HTTP API, where
26
+ * `tier` is `mintTier(target, role)` (credentials.ts) — `role` itself unless `target.roles`
27
+ * overrides it (K-T1).
25
28
  *
26
29
  * 🔴 IT SHELLED OUT TO `bao read -format=json` UNTIL 2026-09-14. The case for the CLI was that one
27
30
  * client could not disagree with `bao kv get` about what this machine may do. <estate>/openbao then
@@ -46,7 +49,11 @@ const baoVariable = (env: BaoEnvironment, name: string) =>
46
49
  */
47
50
  export const mint = (target: ApiTarget, role: PveRole, env: BaoEnvironment = process.env) =>
48
51
  Effect.gen(function* () {
49
- const path = `${target.mount}/creds/${role}`;
52
+ // ⛔ THE ONE PLACE A `PveRole` BECOMES A TIER NAME. `mintTier` (credentials.ts) reads
53
+ // `target.roles` when the target overrides it; every other line in this function uses
54
+ // `tier`, never `role`, for anything that reaches OpenBao or an error message.
55
+ const tier = mintTier(target, role);
56
+ const path = `${target.mount}/creds/${tier}`;
50
57
  // ⛔ METHOD, PATH AND OpenBao's `errors` ONLY — never a header (the token), never a success
51
58
  // body (the credential is IN it, so echoing it would print the secret into a log).
52
59
  const refuse = (status: number, detail: string) =>
@@ -125,7 +132,7 @@ export const mint = (target: ApiTarget, role: PveRole, env: BaoEnvironment = pro
125
132
  * so it is the one signal trusted here.
126
133
  */
127
134
  if (status === 403 && isOpenBaoBody) {
128
- return yield* Effect.fail(new PveCredentialDenied({ detail, mount: target.mount, role }));
135
+ return yield* Effect.fail(new PveCredentialDenied({ detail, mount: target.mount, tier }));
129
136
  }
130
137
  return yield* Effect.fail(refuse(status, detail || '(no errors given)'));
131
138
  }
@@ -135,7 +142,7 @@ export const mint = (target: ApiTarget, role: PveRole, env: BaoEnvironment = pro
135
142
  // ⛔ REFUSE RATHER THAN RETURN A HALF CREDENTIAL, and again WITHOUT quoting the body.
136
143
  return yield* Effect.fail(
137
144
  new Error(
138
- `${target.mount}/creds/${role} returned no token_id/secret. Either this approle lacks ` +
145
+ `${target.mount}/creds/${tier} returned no token_id/secret. Either this approle lacks ` +
139
146
  `the grant, or the mount is not ${target.mount}. Say which mount and role you needed ` +
140
147
  `than falling back to a stored credential -- every stored PVE token is read-only.`,
141
148
  ),
@@ -94,6 +94,7 @@ import {
94
94
  } from './node-network-wire.ts';
95
95
  import type { NodeNetworkAttributes } from './node-network-wire.ts';
96
96
  import { runPve } from './distilled-pve.ts';
97
+ import { mintTier } from './credentials.ts';
97
98
  import { type PveRequirements } from './resource-spec.ts';
98
99
  import { UNREADABLE, unreadableWarning } from './unreadable-read.ts';
99
100
 
@@ -173,7 +174,12 @@ export const ProxmoxNodeNetworkProvider = () =>
173
174
  // ⛔ THE CRIES-WOLF FIX: a refused mint used to fall into `undefined` and force `update`
174
175
  // on an interface that was plainly there — see unreadable-read.ts.
175
176
  if (live === UNREADABLE) {
176
- yield* unreadableWarning('Proxmox.NodeNetwork', `${news.node}/${news.iface}`);
177
+ yield* unreadableWarning(
178
+ 'Proxmox.NodeNetwork',
179
+ `${news.node}/${news.iface}`,
180
+ news.target.mount,
181
+ mintTier(news.target, 'read'),
182
+ );
177
183
  return { action: 'noop' } as const;
178
184
  }
179
185
  if (live === undefined) {
@@ -64,6 +64,7 @@ import {
64
64
  } from './role-wire.ts';
65
65
  import type { PveRequirements, WithTarget } from './resource-spec.ts';
66
66
  import { runPve } from './distilled-pve.ts';
67
+ import { mintTier } from './credentials.ts';
67
68
  import {
68
69
  UNREADABLE,
69
70
  type Unreadable,
@@ -151,7 +152,12 @@ export const ProxmoxRoleProvider = () =>
151
152
  // ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and force
152
153
  // `update` on a role that was plainly there — see unreadable-read.ts.
153
154
  if (live === UNREADABLE) {
154
- yield* unreadableWarning('Proxmox.Role', news.roleid);
155
+ yield* unreadableWarning(
156
+ 'Proxmox.Role',
157
+ news.roleid,
158
+ news.target.mount,
159
+ mintTier(news.target, 'read'),
160
+ );
155
161
  return { action: 'noop' } as const;
156
162
  }
157
163
  if (live === undefined) {
@@ -86,6 +86,7 @@ import {
86
86
  updateForm,
87
87
  } from './storage-form.ts';
88
88
  import { dropUnreadable, matches, readStorage, readStorageOrFail } from './storage-wire.ts';
89
+ import { mintTier } from './credentials.ts';
89
90
  import { UNREADABLE, unreadableWarning } from './unreadable-read.ts';
90
91
  import type { StorageAttributes } from './storage-wire.ts';
91
92
  import type { StorageLocator } from './storage-form.ts';
@@ -182,7 +183,12 @@ export const ProxmoxStorageProvider = () =>
182
183
  // ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and force
183
184
  // `update` on a storage that was plainly there — see unreadable-read.ts.
184
185
  if (live === UNREADABLE) {
185
- yield* unreadableWarning('Proxmox.Storage', news.storage);
186
+ yield* unreadableWarning(
187
+ 'Proxmox.Storage',
188
+ news.storage,
189
+ news.target.mount,
190
+ mintTier(news.target, 'provision'),
191
+ );
186
192
  return { action: 'noop' } as const;
187
193
  }
188
194
  if (live === undefined) {
@@ -51,10 +51,22 @@ export const readOrUnreadable = <A, E, R>(
51
51
  * in `bun run plan`'s own output (Effect's default logger), even though the Diff type has
52
52
  * nowhere to carry the distinction through to the printed action. See the header for why a log
53
53
  * line, not a failed diff or a fourth Diff action, is what "the plan reports" means here.
54
+ *
55
+ * ⛔ M6 (Opus red team, K-T1): NAMES `mount` AND `tier`, NOT JUST THE RESOURCE. Before this, the
56
+ * line read "no grant for this credential's role" with nothing to act on — an operator debugging
57
+ * a scoped-down `PveTarget.roles` override (the whole point of K-T1) had to go re-derive which
58
+ * mount and tier the read actually asked OpenBao for before they could even open that mount's
59
+ * policy. `PveCredentialDenied`'s own message already made this exact call — reading `tier`, not
60
+ * `role`, per that class's own ⛔ — so this warning takes the same two fields for the same reason.
54
61
  */
55
- export const unreadableWarning = (type: string, identity: string): Effect.Effect<void> =>
62
+ export const unreadableWarning = (
63
+ type: string,
64
+ identity: string,
65
+ mount: string,
66
+ tier: string,
67
+ ): Effect.Effect<void> =>
56
68
  Effect.logWarning(
57
- `${type} ${identity}: the read was refused (no grant for this credential's role) -- ` +
58
- 'reporting noop rather than forcing an update with nothing compared. Re-plan on a lane ' +
59
- 'that can read this object before trusting this row.',
69
+ `${type} ${identity}: the read of ${mount}/creds/${tier} was refused (no grant for this ` +
70
+ "credential's role) -- reporting noop rather than forcing an update with nothing compared. " +
71
+ 'Re-plan on a lane that can read this object before trusting this row.',
60
72
  );
@@ -60,6 +60,7 @@ import * as Effect from 'effect/Effect';
60
60
  import { guardWrite } from './distilled-guard.ts';
61
61
  import type { PveRequirements, WithTarget } from './resource-spec.ts';
62
62
  import { runPve } from './distilled-pve.ts';
63
+ import { mintTier } from './credentials.ts';
63
64
  import { UNREADABLE, unreadableWarning } from './unreadable-read.ts';
64
65
  import {
65
66
  USER_CREATE,
@@ -162,7 +163,12 @@ export const ProxmoxUserProvider = () =>
162
163
  // ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and force
163
164
  // `update` on an account that was plainly there — see unreadable-read.ts.
164
165
  if (live === UNREADABLE) {
165
- yield* unreadableWarning('Proxmox.User', news.userid);
166
+ yield* unreadableWarning(
167
+ 'Proxmox.User',
168
+ news.userid,
169
+ news.target.mount,
170
+ mintTier(news.target, 'read'),
171
+ );
166
172
  return { action: 'noop' } as const;
167
173
  }
168
174
  if (live === undefined) {
@@ -78,6 +78,7 @@ import * as Effect from 'effect/Effect';
78
78
  import { asForm } from './distilled-guard.ts';
79
79
  import { guardForm } from './constraint-guard.ts';
80
80
  import type { PveRequirements, WithTarget } from './resource-spec.ts';
81
+ import { mintTier } from './credentials.ts';
81
82
  import { UNREADABLE, unreadableWarning } from './unreadable-read.ts';
82
83
  import { dropUnreadable, readPool, readPoolOrFail } from './zfs-pool-wire.ts';
83
84
  import type { ZfsPoolAttributes } from './zfs-pool-wire.ts';
@@ -202,7 +203,12 @@ export const ProxmoxZfsPoolProvider = () =>
202
203
  // update, the cries-wolf class of bug.
203
204
  const live = yield* readPoolOrFail(news);
204
205
  if (live === UNREADABLE) {
205
- yield* unreadableWarning('Proxmox.ZfsPool', `${news.node}/${news.name}`);
206
+ yield* unreadableWarning(
207
+ 'Proxmox.ZfsPool',
208
+ `${news.node}/${news.name}`,
209
+ news.target.mount,
210
+ mintTier(news.target, 'read'),
211
+ );
206
212
  return { action: 'noop' } as const;
207
213
  }
208
214
  // ⛔ FOUND WRITING THIS FILE, 2026-09-24 — an earlier edit that removed the dead