@homeflare/alchemy 0.31.1 → 0.31.2

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.
@@ -1 +1 @@
1
- {"version":3,"file":"role.d.ts","sourceRoot":"","sources":["../../src/proxmox/role.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAa7C,OAAO,KAAK,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAStE,MAAM,WAAW,SAAU,SAAQ,UAAU;IAC3C;;;;;;OAMG;IACH,MAAM,EAAE,MAAM,CAAC;IACf;;;;;;;OAOG;IACH,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,4FAA4F;IAC5F,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,WAAY,SAAQ,QAAQ,CAC3C,cAAc,EACd,SAAS,EACT,cAAc,EACd,KAAK,EACL,eAAe,CAChB;CAAG;AAEJ,eAAO,MAAM,WAAW,8CAAwC,CAAC;AAiBjE,eAAO,MAAM,mBAAmB,iIA+E7B,CAAC"}
1
+ {"version":3,"file":"role.d.ts","sourceRoot":"","sources":["../../src/proxmox/role.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAa7C,OAAO,KAAK,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAStE,MAAM,WAAW,SAAU,SAAQ,UAAU;IAC3C;;;;;;OAMG;IACH,MAAM,EAAE,MAAM,CAAC;IACf;;;;;;;OAOG;IACH,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,4FAA4F;IAC5F,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,WAAY,SAAQ,QAAQ,CAC3C,cAAc,EACd,SAAS,EACT,cAAc,EACd,KAAK,EACL,eAAe,CAChB;CAAG;AAEJ,eAAO,MAAM,WAAW,8CAAwC,CAAC;AAwBjE,eAAO,MAAM,mBAAmB,iIA+E7B,CAAC"}
@@ -1,18 +1,47 @@
1
1
  /**
2
- * `Proxmox.User`'s wire shape: PVE's create/update forms and how a live item becomes
3
- * attributes. Split out of user.ts (2026-09-24, the distilled migration) to keep that file
4
- * under the 250-line cap — the api-token.ts/api-token-form.ts seam.
2
+ * `Proxmox.User`'s wire shape: PVE's create/update forms, how a live item becomes attributes,
3
+ * and the read itself. Split out of user.ts (2026-09-24, the distilled migration) to keep that
4
+ * file under the 250-line cap — the api-token.ts/api-token-form.ts seam.
5
5
  */
6
- import type * as access from '@distilled.cloud/proxmox/access';
6
+ import * as access from '@distilled.cloud/proxmox/access';
7
+ import * as Effect from 'effect/Effect';
7
8
  import type { UserAttributes, UserProps } from './user.ts';
9
+ import { UNREADABLE, type Unreadable } from './unreadable-read.ts';
8
10
  export declare const USER_CREATE = "pve:POST /access/users";
9
11
  export declare const USER_UPDATE = "pve:PUT /access/users/{userid}";
10
12
  export declare const createForm: (props: UserProps) => access.CreateAccessUserRequest;
11
13
  export declare const updateForm: (props: UserProps) => access.PutAccessUserRequest;
12
- export declare const attributesOf: (live: access.GetAccessUserResponse, props: UserProps) => UserAttributes;
13
14
  /**
14
15
  * ⚠️ `tokens` IS NOT COMPARED, and neither is anything create-only. What is compared is exactly
15
16
  * what a PUT can put back; a field PVE reports but will not accept is drift nobody can fix.
16
17
  */
17
18
  export declare const matches: (attributes: UserAttributes, props: UserProps) => boolean;
19
+ /**
20
+ * ★ Default `read` role for this family — only the write side needs `provision`.
21
+ * ⛔ `{ userid: props.userid }`, NEVER THE WHOLE `props` — MEASURED 2026-09-24, the regression in
22
+ * kit 0.31.1. `GetAccessUserRequest`'s schema declares only `userid` (a path label); any OTHER
23
+ * key on the object passed to `access.getAccessUser` — `target`, `comment`, every field this
24
+ * family's own props carry — is treated by distilled's `buildRequest` as an "unknown key" and
25
+ * JSON-encoded onto the request as a BODY, on every one of these bodyless GETs. A stricter
26
+ * fetch client than a bare `bun run` of this file refuses a GET carrying any body at all
27
+ * (`TypeError [ERR_INVALID_ARG_VALUE]: fetch() request with GET/HEAD method cannot have body`),
28
+ * which `runPveWith` retries across every member and exhausts identically —
29
+ * `PveClusterExhausted`, then silently folded to "absent" by the old `orElseSucceed`, forcing a
30
+ * false `update` on every account.
31
+ */
32
+ export declare const readUserOrFail: (props: UserProps) => Effect.Effect<typeof UNREADABLE | UserAttributes, import("@distilled.cloud/caddy").BadGateway | import("@distilled.cloud/caddy").BadRequest | import("@distilled.cloud/caddy").ConfigError | Error | import("@distilled.cloud/caddy").GatewayTimeout | import("effect/unstable/http/HttpClientError").HttpClientError | import("@distilled.cloud/caddy").InternalServerError | import("@distilled.cloud/proxmox/Errors").ParameterVerificationFailed | import("@distilled.cloud/proxmox/Errors").ProxmoxParseError | import("./distilled-pve.ts").PveClusterExhausted | import("@distilled.cloud/caddy").ServiceUnavailable | import("effect/Cause").TimeoutError | import("@distilled.cloud/caddy").TooManyRequests | import("@distilled.cloud/caddy").Unauthorized | import("@distilled.cloud/proxmox/Errors").UnknownProxmoxError, import("effect/unstable/http/HttpClient").HttpClient>;
33
+ /**
34
+ * ⛔ FOLDS A GENUINE READ FAILURE TO `undefined` TOO, AND ONLY `read`/`reconcile` MAY USE IT. A
35
+ * missing user is a thrown 500 (user.ts's own header), so `reconcile`'s create branch NEEDS
36
+ * "absent" from a failure to ever run — without this fold, a brand-new account could never be
37
+ * created. Safe here because a wrongful fold costs at most a redundant `createAccessUser` POST,
38
+ * which PVE refuses loudly rather than silently corrupting anything.
39
+ * ⛔ `user.ts`'s `diff` does **NOT** use this — the SAME bug class as the credential denial fix:
40
+ * folding a TRANSIENT failure into "absent" at PLAN TIME is what forced a false `update` with
41
+ * nothing compared. `diff` calls `readUserOrFail` directly, so a genuine failure there
42
+ * propagates and fails the whole plan loudly instead.
43
+ */
44
+ export declare const readUser: (props: UserProps) => Effect.Effect<typeof UNREADABLE | UserAttributes | undefined, never, import("effect/unstable/http/HttpClient").HttpClient>;
45
+ /** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
46
+ export declare const dropUnreadable: (live: UserAttributes | Unreadable | undefined) => UserAttributes | undefined;
18
47
  //# sourceMappingURL=user-wire.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"user-wire.d.ts","sourceRoot":"","sources":["../../src/proxmox/user-wire.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,KAAK,KAAK,MAAM,MAAM,iCAAiC,CAAC;AAC/D,OAAO,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAG3D,eAAO,MAAM,WAAW,2BAA2B,CAAC;AACpD,eAAO,MAAM,WAAW,mCAAmC,CAAC;AA2D5D,eAAO,MAAM,UAAU,UAAW,SAAS,KAAG,MAAM,CAAC,uBAGnD,CAAC;AAEH,eAAO,MAAM,UAAU,UAAW,SAAS,KAAG,MAAM,CAAC,oBAGnD,CAAC;AAEH,eAAO,MAAM,YAAY,SACjB,MAAM,CAAC,qBAAqB,SAC3B,SAAS,KACf,cAgBD,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,OAAO,eAAgB,cAAc,SAAS,SAAS,YAOF,CAAC"}
1
+ {"version":3,"file":"user-wire.d.ts","sourceRoot":"","sources":["../../src/proxmox/user-wire.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,KAAK,MAAM,MAAM,iCAAiC,CAAC;AAC1D,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAExC,OAAO,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAC3D,OAAO,EAAE,UAAU,EAAE,KAAK,UAAU,EAAoB,MAAM,sBAAsB,CAAC;AAGrF,eAAO,MAAM,WAAW,2BAA2B,CAAC;AACpD,eAAO,MAAM,WAAW,mCAAmC,CAAC;AA2D5D,eAAO,MAAM,UAAU,UAAW,SAAS,KAAG,MAAM,CAAC,uBAGnD,CAAC;AAEH,eAAO,MAAM,UAAU,UAAW,SAAS,KAAG,MAAM,CAAC,oBAGnD,CAAC;AAoBH;;;GAGG;AACH,eAAO,MAAM,OAAO,eAAgB,cAAc,SAAS,SAAS,YAOF,CAAC;AAEnE;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,cAAc,UAAW,SAAS,g2BAG+C,CAAC;AAE/F;;;;;;;;;;GAUG;AACH,eAAO,MAAM,QAAQ,UAAW,SAAS,+HAC0B,CAAC;AAEpE,gGAAgG;AAChG,eAAO,MAAM,cAAc,SAAU,cAAc,GAAG,UAAU,GAAG,SAAS,+BACpC,CAAC"}
@@ -33,12 +33,12 @@
33
33
  * user, so a role wide enough to edit the account can still be refused for one group.
34
34
  * · `Sys.Audit` (or `User.Modify`) for the read, which the `read` role's auditor already has.
35
35
  *
36
- * ⚠️ A MISSING USER IS A 500, NOT A 404 — "no such user ('x@pve')". distilled turns that into an
37
- * `UnknownProxmoxError` (protocol.ts), and `readOrUnreadable`'s own `orElseSucceed` folds it
38
- * into "absent" same as the old hand-rolled client did. A 403 therefore also reads as absent
39
- * UNLESS it is the one refusal `readOrUnreadable` catches (unreadable-read.ts): a REFUSED
40
- * `provision` mint, the cries-wolf case. Any other refusal still reads as absent, and the
41
- * honest error arrives one step later, from the create.
36
+ * ⚠️ A MISSING USER IS A 500, NOT A 404 — MEASURED against the live cluster, "no such user
37
+ * ('x@pve')". distilled turns that into an `InternalServerError`, with no distinct "not found"
38
+ * tag to catch. `user-wire.ts`'s `readUser` (used by `read`/`reconcile`, where the create
39
+ * workflow needs "absent" from exactly this failure) still folds it; `readUserOrFail` (used by
40
+ * `diff` below) does not — see that function's own header for why folding it there was the
41
+ * cries-wolf bug all over again, just for a genuine PVE quirk instead of a permission denial.
42
42
  *
43
43
  * ⚠️ THE WIRE URL FOR THIS FAMILY CHANGED, THOUGH NOTHING A CALLER SEES DID. MEASURED
44
44
  * (user.test.ts): distilled's `{userid}` label substitution percent-encodes it —
@@ -1 +1 @@
1
- {"version":3,"file":"user.d.ts","sourceRoot":"","sources":["../../src/proxmox/user.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAI7C,OAAO,KAAK,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAiBtE,MAAM,WAAW,SAAU,SAAQ,UAAU;IAC3C;;;;;;;OAOG;IACH,MAAM,EAAE,MAAM,CAAC;IACf,iCAAiC;IACjC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,4EAA4E;IAC5E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mGAAmG;IACnG,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,kFAAkF;IAClF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,2FAA2F;IAC3F,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,qFAAqF;IACrF,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,WAAY,SAAQ,QAAQ,CAC3C,cAAc,EACd,SAAS,EACT,cAAc,EACd,KAAK,EACL,eAAe,CAChB;CAAG;AAEJ,eAAO,MAAM,WAAW,8CAAwC,CAAC;AAajE,eAAO,MAAM,mBAAmB,iIAgF7B,CAAC"}
1
+ {"version":3,"file":"user.d.ts","sourceRoot":"","sources":["../../src/proxmox/user.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAI7C,OAAO,KAAK,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AActE,MAAM,WAAW,SAAU,SAAQ,UAAU;IAC3C;;;;;;;OAOG;IACH,MAAM,EAAE,MAAM,CAAC;IACf,iCAAiC;IACjC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,4EAA4E;IAC5E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mGAAmG;IACnG,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,kFAAkF;IAClF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,2FAA2F;IAC3F,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,qFAAqF;IACrF,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,WAAY,SAAQ,QAAQ,CAC3C,cAAc,EACd,SAAS,EACT,cAAc,EACd,KAAK,EACL,eAAe,CAChB;CAAG;AAEJ,eAAO,MAAM,WAAW,8CAAwC,CAAC;AAEjE,eAAO,MAAM,mBAAmB,iIAgG7B,CAAC"}
@@ -48,11 +48,25 @@ export declare const writesOf: (cloud: Cloud) => string[];
48
48
  export declare const thingProviders: (cloud: Cloud) => Layer.Layer<Alchemy.Provider<Blind> | Alchemy.Provider<Recorded> | Alchemy.Provider<Thing>, never, never>;
49
49
  /** A stack body: resource declarations, as an `alchemy.run.ts` would write them. */
50
50
  export type Body = Effect.Effect<unknown, unknown, unknown>;
51
+ /** One row of `Alchemy.Drift.detect`'s dry-run report — the fields `drift` below asserts on. */
52
+ export type DriftRow = {
53
+ readonly action: string;
54
+ };
51
55
  export interface FakeEngine {
52
56
  /** Plan and apply `body`, as `alchemy deploy --adopt` would; resolves to the planned actions. */
53
57
  readonly deploy: (body: Body) => Promise<Record<string, string>>;
54
58
  /** Verify `body` against what the earlier deploys recorded. */
55
59
  readonly verify: (body: Body, options?: VerifyOptions) => Promise<AdoptReport>;
60
+ /**
61
+ * `alchemy drift`'s own dry-run (`Alchemy.Drift.detect`) over whatever `deploy` already
62
+ * persisted — `body` is used only to resolve the stack's name/stage/providers, exactly as
63
+ * `verify` does; drift itself reads the state store, not `body`'s declarations. ⚠️ A DIFFERENT
64
+ * ENGINE PATH FROM `verify`'s `--all` READ, proxmox/read-failure-propagation.test.ts's own ⛔:
65
+ * `Drift.ts` calls a provider's `read` with `output` set to the PERSISTED attributes, and folds
66
+ * a `read` that resolves `undefined` into `{action: 'missing'}` with no error — silent, unlike
67
+ * `verify.ts`'s `readWithState`, which already wraps that same call in `Effect.catchCause`.
68
+ */
69
+ readonly drift: (body: Body) => Promise<Record<string, DriftRow>>;
56
70
  /**
57
71
  * Every state row the store holds, as JSON — what a Postgres store would persist. ★ For tests
58
72
  * that must prove a value NEVER reaches state (proxmox/pbs-notification-target-state.test.ts).
@@ -1 +1 @@
1
- {"version":3,"file":"fake-engine.d.ts","sourceRoot":"","sources":["../../src/verify/fake-engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,KAAK,OAAO,MAAM,SAAS,CAAC;AAKnC,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,KAAK,MAAM,cAAc,CAAC;AACtC,OAAO,EAAE,KAAK,WAAW,EAAE,KAAK,aAAa,EAAiB,MAAM,aAAa,CAAC;AAElF,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AACD,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,KAAM,SAAQ,OAAO,CAAC,QAAQ,CAAC,YAAY,EAAE,UAAU,EAAE,eAAe,CAAC;CAAG;AAC7F,eAAO,MAAM,KAAK,8BAAwC,CAAC;AAE3D,4EAA4E;AAC5E,MAAM,WAAW,KAAM,SAAQ,OAAO,CAAC,QAAQ,CAAC,YAAY,EAAE,UAAU,EAAE,eAAe,CAAC;CAAG;AAC7F,eAAO,MAAM,KAAK,8BAAwC,CAAC;AAE3D;;;;GAIG;AACH,MAAM,WAAW,QAAS,SAAQ,OAAO,CAAC,QAAQ,CAAC,eAAe,EAAE,UAAU,EAAE,eAAe,CAAC;CAAG;AACnG,eAAO,MAAM,QAAQ,iCAA8C,CAAC;AAEpE,gGAAgG;AAChG,MAAM,WAAW,KAAK;IACpB,QAAQ,CAAC,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,QAAQ,CAAC,OAAO,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC9B,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,CAAC;CAC1B;AAED,eAAO,MAAM,OAAO,UAAU,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAQ,KAI1D,CAAC;AAEH,8CAA8C;AAC9C,eAAO,MAAM,QAAQ,UAAW,KAAK,KAAG,MAAM,EAC2C,CAAC;AAyB1F,4FAA4F;AAC5F,eAAO,MAAM,cAAc,UAAW,KAAK,8GAmCxC,CAAC;AAEJ,oFAAoF;AACpF,MAAM,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;AAK5D,MAAM,WAAW,UAAU;IACzB,iGAAiG;IACjG,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACjE,+DAA+D;IAC/D,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,CAAC,EAAE,aAAa,KAAK,OAAO,CAAC,WAAW,CAAC,CAAC;IAC/E;;;OAGG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,MAAM,CAAC;CAC/B;AAED;;;;GAIG;AACH,eAAO,MAAM,UAAU,GAAI,IAAI,EAAE,CAAC,EAAE,GAAG,SAAS,KAAK,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,EAAE,GAAG,CAAC,KAAG,UAyC3E,CAAC;AAEF,oCAAoC;AACpC,eAAO,MAAM,UAAU,UAAW,KAAK,KAAG,UAA+C,CAAC"}
1
+ {"version":3,"file":"fake-engine.d.ts","sourceRoot":"","sources":["../../src/verify/fake-engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,KAAK,OAAO,MAAM,SAAS,CAAC;AAKnC,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,KAAK,MAAM,cAAc,CAAC;AACtC,OAAO,EAAE,KAAK,WAAW,EAAE,KAAK,aAAa,EAAiB,MAAM,aAAa,CAAC;AAElF,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AACD,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,KAAM,SAAQ,OAAO,CAAC,QAAQ,CAAC,YAAY,EAAE,UAAU,EAAE,eAAe,CAAC;CAAG;AAC7F,eAAO,MAAM,KAAK,8BAAwC,CAAC;AAE3D,4EAA4E;AAC5E,MAAM,WAAW,KAAM,SAAQ,OAAO,CAAC,QAAQ,CAAC,YAAY,EAAE,UAAU,EAAE,eAAe,CAAC;CAAG;AAC7F,eAAO,MAAM,KAAK,8BAAwC,CAAC;AAE3D;;;;GAIG;AACH,MAAM,WAAW,QAAS,SAAQ,OAAO,CAAC,QAAQ,CAAC,eAAe,EAAE,UAAU,EAAE,eAAe,CAAC;CAAG;AACnG,eAAO,MAAM,QAAQ,iCAA8C,CAAC;AAEpE,gGAAgG;AAChG,MAAM,WAAW,KAAK;IACpB,QAAQ,CAAC,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,QAAQ,CAAC,OAAO,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC9B,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,CAAC;CAC1B;AAED,eAAO,MAAM,OAAO,UAAU,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAQ,KAI1D,CAAC;AAEH,8CAA8C;AAC9C,eAAO,MAAM,QAAQ,UAAW,KAAK,KAAG,MAAM,EAC2C,CAAC;AAyB1F,4FAA4F;AAC5F,eAAO,MAAM,cAAc,UAAW,KAAK,8GAmCxC,CAAC;AAEJ,oFAAoF;AACpF,MAAM,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;AAK5D,gGAAgG;AAChG,MAAM,MAAM,QAAQ,GAAG;IAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAEnD,MAAM,WAAW,UAAU;IACzB,iGAAiG;IACjG,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACjE,+DAA+D;IAC/D,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,CAAC,EAAE,aAAa,KAAK,OAAO,CAAC,WAAW,CAAC,CAAC;IAC/E;;;;;;;;OAQG;IACH,QAAQ,CAAC,KAAK,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC;IAClE;;;OAGG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,MAAM,CAAC;CAC/B;AAED;;;;GAIG;AACH,eAAO,MAAM,UAAU,GAAI,IAAI,EAAE,CAAC,EAAE,GAAG,SAAS,KAAK,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,EAAE,GAAG,CAAC,KAAG,UAmD3E,CAAC;AAEF,oCAAoC;AACpC,eAAO,MAAM,UAAU,UAAW,KAAK,KAAG,UAA+C,CAAC"}
package/docs/grafana.md CHANGED
@@ -8,32 +8,61 @@ publicly at `tesla.homeflare.dev/dash/`.
8
8
 
9
9
  ## The SDK gap — why only `Datasource` ships
10
10
 
11
+ ✅ **SDK-level gap fixed 2026-09-24** (`@distilled.cloud/grafana`, aliased onto
12
+ `@homeflare/distilled-grafana` — 0.1.0 pre-release, 0.2.0 once its `minor` changeset publishes it;
13
+ see [docs/distilled-interim.md](./distilled-interim.md)).
14
+ The gap below is otherwise unchanged and left as evidence of what was measured and why; only its
15
+ status line moved. **Still true today:** only `Grafana.Datasource` ships as a house _resource_ —
16
+ `Grafana.Folder`, `Grafana.Dashboard`, `Grafana.AlertRule` and friends are follow-up work on top of
17
+ the now-available SDK operations, not part of this fix.
18
+
11
19
  Measured 2026-09-24 against the published `@distilled.cloud/grafana@1.0.0-rc.12` tarball (its
12
- `lib/services/grafana.d.ts`, 4,938 lines): the package has **no create/read/update/delete
13
- operations for folders, dashboards, alert rules or contact points**. It covers datasources in
20
+ `lib/services/grafana.d.ts`, 4,938 lines): the package had **no create/read/update/delete
21
+ operations for folders, dashboards, alert rules or contact points**. It covered datasources in
14
22
  full (`addDataSource`, `getDataSourceByUID`, `updateDataSourceByUID`, `deleteDataSourceByUID`,
15
23
  `getDataSources`), plus org/user/team/role/report/annotation/library-element/public-dashboard/
16
24
  snapshot management — but:
17
25
 
18
- - **Folders:** only `updateFolderPermissions` exists. No `createFolder`, `getFolder` or
19
- `deleteFolder` — Grafana's `/api/folders` and `/api/folders/{uid}` routes are entirely absent.
20
- - **Dashboards:** only snapshot and public-dashboard routes exist (`createDashboardSnapshot`,
26
+ - **Folders:** only `updateFolderPermissions` existed. No `createFolder`, `getFolder` or
27
+ `deleteFolder` — Grafana's `/api/folders` and `/api/folders/{uid}` routes were entirely absent.
28
+ - **Dashboards:** only snapshot and public-dashboard routes existed (`createDashboardSnapshot`,
21
29
  `getPublicDashboard`, …). No `POST /api/dashboards/db`, no `GET /api/dashboards/uid/{uid}`, no
22
- `DELETE /api/dashboards/uid/{uid}` — the plain dashboard CRUD route family is absent.
30
+ `DELETE /api/dashboards/uid/{uid}` — the plain dashboard CRUD route family was absent.
23
31
  - **Alert rules:** only `routeGetAlertRuleExport`/`routeGetAlertRuleGroupExport`/
24
- `routeGetAlertRulesExport` exist — export (read-only) endpoints under `/api/v1/provisioning/`.
32
+ `routeGetAlertRulesExport` existed — export (read-only) endpoints under `/api/v1/provisioning/`.
25
33
  No create/update/delete for `/api/v1/provisioning/alert-rules{,/{uid}}`.
26
- - **Contact points:** only `routeGetContactpointsExport` exists, same story — no
34
+ - **Contact points:** only `routeGetContactpointsExport` existed, same story — no
27
35
  `/api/v1/provisioning/contact-points{,/{uid}}` CRUD.
28
36
 
29
- This is recorded in [`docs/upstream-conformance.md`](./upstream-conformance.md) rather than worked
37
+ **Root cause, measured (not the spec lacking the paths):** Grafana's own OpenAPI document has
38
+ every one of the routes above — the converter dropped them. `skipDeprecated: true` (the distilled
39
+ converter's v0 default) skips any operation the spec marks `deprecated: true`, and Grafana marks
40
+ its entire classic folder/dashboard/alerting-provisioning WRITE surface deprecated, each
41
+ operation's `description` pointing at a `/apis/<group>.grafana.app/v1/...` Kubernetes-apiserver
42
+ route that is a separate API server with its own OpenAPI document — absent from this spec
43
+ entirely, and not what this family's `Grafana.Datasource` (or any planned `Grafana.Folder`/
44
+ `Grafana.Dashboard`/etc.) targets. The fix patches `deprecated` back to `false` on exactly the 32
45
+ operations this gap named (leaving the other 32 deprecated operations — playlists, org/user
46
+ preferences, datasources-by-name, licensing, reports, dashboard stars/tags/versions — excluded, as
47
+ before): see the distilled clone's `packages/grafana/scripts/convert.ts` header and
48
+ `patches/*.patch.json` for the exact list and reasoning, and
49
+ `packages/distilled-grafana/README.md` in this kit for the copied result. `Grafana.Datasource`'s
50
+ own operations (`addDataSource`/`getDataSourceByUID`/`updateDataSourceByUID`/
51
+ `deleteDataSourceByUID`) were never deprecated and are untouched — same props, same attributes,
52
+ same generated code, confirmed by diffing the distilled clone's regenerated output against its
53
+ prior commit operation-by-operation.
54
+
55
+ This was recorded in [`docs/upstream-conformance.md`](./upstream-conformance.md) rather than worked
30
56
  around. Per S23, the fix is a distilled PR (regenerate against a spec that includes these routes,
31
- or patch them in per S22), not a hand-rolled `HttpClient` client in this package for the missing
32
- pieces alone — that would put half a family through the SDK and half through a second, parallel
33
- client, exactly what S23 exists to prevent.
34
-
35
- `teslamate-grafana` itself is consistent with this gap being real, not merely unexploited: its one
36
- datasource is API-manageable and already exists; its dashboards ship baked into the
57
+ or patch them in per S22) — not a hand-rolled `HttpClient` client in this package for the missing
58
+ pieces alone, which would put half a family through the SDK and half through a second, parallel
59
+ client, exactly what S23 exists to prevent. It shipped as a kit interim package
60
+ (`@homeflare/distilled-grafana`), the same S22 route `@homeflare/distilled-netbox` established,
61
+ because `alchemy-run/distilled` is upstream and this house never pushes to it (decision 42) — see
62
+ docs/distilled-interim.md.
63
+
64
+ `teslamate-grafana` itself is consistent with this gap having been real, not merely unexploited:
65
+ its one datasource is API-manageable and already exists; its dashboards ship baked into the
37
66
  `teslamate/grafana` image and are provisioned by the container, not by any API call; it has no
38
67
  folders, alert rules or contact points configured at all (measured over ssh, read-only, 2026-09-24).
39
68
 
@@ -96,7 +125,8 @@ export class TeslaMateDatasource extends GrafanaDatasource('teslamate-datasource
96
125
 
97
126
  ## Not covered
98
127
 
99
- - **Folders, dashboards, alert rules, contact points.** Blocked on the SDK gap above — a
100
- maintainer decision on whether to patch distilled or wait for upstream to add the routes.
128
+ - **Folders, dashboards, alert rules, contact points, notification policies, mute timings.** The
129
+ SDK-level blocker is fixed (above); no house `Resource` calls these operations yet — follow-up
130
+ work, not blocked on anything.
101
131
  - **`read` never answers `Unowned`** — the same open gap forgejo's and netbox's own families
102
132
  still carry (see upstream-conformance.md); a maintainer decision on the ownership-check shape.
@@ -249,10 +249,20 @@ HttpClient` client. The old status-carrying `NetboxError` and its `cause.status
249
249
  `GET/DELETE /dashboards/uid/{uid}`), alert rules or contact points (both have only a
250
250
  `routeGet*Export` read-only route under `/v1/provisioning/`, no create/update/delete). S1
251
251
  found no upstream `Alchemy` family for any of these either. Full detail:
252
- [grafana.md](./grafana.md#the-sdk-gap--why-only-datasource-ships). **Decision:** maintainer,
253
- on whether the kit patches distilled (S22) or waits for upstream to add the routes — S23
254
- forbids a hand-rolled `HttpClient` client for the missing pieces alone, half a family through
255
- the SDK and half through a second client.
252
+ [grafana.md](./grafana.md#the-sdk-gap--why-only-datasource-ships).
253
+
254
+ ✅ **S22 fixed 2026-09-24** (branch `claude2/distilled-grafana`): root cause measured to be
255
+ `skipDeprecated: true` dropping 64 operations Grafana's own spec marks `deprecated: true` in
256
+ favor of a separate, undocumented-here Kubernetes-apiserver route — not a spec omission. RFC-6902
257
+ patches un-deprecate exactly the 32 operations this finding named (folders, dashboards,
258
+ alerting-provisioning writes); the other 32 stay excluded. Shipped as
259
+ `@homeflare/distilled-grafana` (0.1.0 pre-release, 0.2.0 once this PR's `minor` changeset
260
+ releases it), aliased onto `@distilled.cloud/grafana` the same S22 route
261
+ `@homeflare/distilled-netbox` established. `Grafana.Datasource`'s props/attributes/generated code
262
+ are unchanged (diffed operation-by-operation against the prior commit). **Still open, and not
263
+ this PR's scope:** no `Grafana.Folder`/`Dashboard`/`AlertRule`/`ContactPoint`/
264
+ `NotificationPolicy`/`MuteTiming` resource calls the newly-available operations yet — follow-up
265
+ PRs, no longer blocked on the SDK.
256
266
  - ⛔ **Same open gaps as `forgejo/*` and `netbox/*` above:** `read` never answers `Unowned`
257
267
  (H1), and credentials come from an explicit env var NAME at call time rather than an
258
268
  `alchemy/Auth` provider (S24) — here the house's own `grafanaCredentials(target)`, not the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
5
5
  "homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
6
6
  "bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
@@ -107,6 +107,7 @@
107
107
  },
108
108
  "dependencies": {
109
109
  "@distilled.cloud/caddy": "npm:@homeflare/distilled-caddy@0.2.2",
110
+ "@distilled.cloud/grafana": "npm:@homeflare/distilled-grafana@0.2.0",
110
111
  "@distilled.cloud/litellm": "npm:@homeflare/distilled-litellm@0.2.0",
111
112
  "@distilled.cloud/netbox": "npm:@homeflare/distilled-netbox@0.3.0",
112
113
  "@distilled.cloud/paperless-ngx": "npm:@homeflare/distilled-paperless-ngx@0.3.0",
@@ -118,7 +119,6 @@
118
119
  "@distilled.cloud/discord": "1.0.0-rc.12",
119
120
  "@distilled.cloud/forgejo": "1.0.0-rc.12",
120
121
  "@distilled.cloud/google-workspace": "1.0.0-rc.12",
121
- "@distilled.cloud/grafana": "1.0.0-rc.12",
122
122
  "@effect/platform-node": "4.0.0-rc.115",
123
123
  "@effect/sql-pg": "4.0.0-rc.115",
124
124
  "alchemy": "2.0.0-beta.79",
@@ -5,11 +5,12 @@
5
5
  * the symbols a real stack consumes; the rest of the files (resource.ts's engine, fake-grafana.ts)
6
6
  * are internals a provider needs but a consumer should not depend on.
7
7
  *
8
- * ⛔ ONLY `Grafana.Datasource` SHIPS HERE. `@distilled.cloud/grafana@1.0.0-rc.12` has no folder,
9
- * dashboard, alert-rule or contact-point CRUD operations — see docs/grafana.md and
10
- * docs/upstream-conformance.md for the measured gap. Adding those resources now would mean
11
- * hand-rolling an `HttpClient` client for them, which S23 forbids for a vendor distilled
12
- * already covers in part; the right fix is upstream, in distilled, not a house workaround here.
8
+ * ⛔ ONLY `Grafana.Datasource` SHIPS HERE. `@distilled.cloud/grafana` (aliased onto
9
+ * `@homeflare/distilled-grafana` — see docs/distilled-interim.md) now HAS folder, dashboard and
10
+ * alerting-provisioning CRUD operations — the SDK-level gap docs/grafana.md and
11
+ * docs/upstream-conformance.md recorded is fixed. Building `Grafana.Folder`, `Grafana.Dashboard`,
12
+ * `Grafana.AlertRule` etc. on top of them is follow-up work this PR deliberately does not do —
13
+ * see docs/grafana.md for what changed and what is still open.
13
14
  */
14
15
  export {
15
16
  type DatasourceAttributes,
@@ -99,15 +99,21 @@ export const ProxmoxAcl = Resource<ProxmoxAcl>('Proxmox.Acl');
99
99
 
100
100
  /**
101
101
  * ⛔ `readRole: 'provision'`, WITHOUT WHICH EVERY GRANT READS BACK AS ABSENT — see the header's
102
- * ⛔ on filtering. `orElseSucceed` still folds every OTHER failure into `undefined`.
102
+ * ⛔ on filtering.
103
103
  * ★ `readOrUnreadable` (unreadable-read.ts) runs first: a REFUSED mint of `provision` — the
104
- * "cries wolf" bug — comes back `UNREADABLE` instead of the same `undefined` a genuine
105
- * absence produces, so `diff` below can tell them apart.
104
+ * "cries wolf" bug — comes back `UNREADABLE` instead of the `undefined` a genuine, SUCCESSFUL
105
+ * read (the row simply not being in the list) produces, so `diff` below can tell them apart.
106
+ * ⛔ NO `orElseSucceed` HERE ANY MORE — found on 2026-09-24, the SAME bug class as the credential
107
+ * denial: it used to fold EVERY OTHER failure (a transport error, `PveClusterExhausted`, a
108
+ * decode failure) into `undefined` too, which then read exactly like a genuine absence and
109
+ * forced the same false `update`. Only a read that actually SUCCEEDS (and the grant is not in
110
+ * the list it returns) may mean absent now; every other failure propagates and fails the whole
111
+ * plan loudly — the engine's own contract for a failed `read`/`diff` (verify.ts's `readWithState`
112
+ * already expects and reports it as `'failed'`).
106
113
  */
107
114
  const readAttributes = (props: AclProps) =>
108
115
  readOrUnreadable(runPve(props.target, 'provision', false, access.listAccessAcl({}))).pipe(
109
116
  Effect.map((rows) => (rows === UNREADABLE ? UNREADABLE : attributesOf(rows, props))),
110
- Effect.orElseSucceed(() => undefined),
111
117
  );
112
118
 
113
119
  /** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
@@ -141,12 +141,22 @@ export const ProxmoxApiToken = Resource<ProxmoxApiToken>('Proxmox.ApiToken', {
141
141
  defaultRemovalPolicy: 'retain',
142
142
  });
143
143
 
144
- /** ⛔ `readRole: 'provision'` — see the header's ⚠️ on privileges. */
145
- const readToken = (props: ApiTokenProps) =>
146
- readOrUnreadable(runPve(props.target, 'provision', false, access.getAccessUserToken(props))).pipe(
147
- Effect.map((live) => (live === UNREADABLE ? UNREADABLE : attributesOf(live, props))),
148
- Effect.orElseSucceed(() => undefined),
149
- );
144
+ /**
145
+ * ⛔ `readRole: 'provision'` — see the header's ⚠️ on privileges.
146
+ * ⛔ `{ userid, tokenid }`, NEVER THE WHOLE `props` — group.ts's readGroup has the full measured
147
+ * mechanism (kit 0.31.1's regression): an extra key beyond the schema's own path labels gets
148
+ * encoded onto this GET as a body, which a stricter fetch client refuses outright.
149
+ * ⛔ NO `orElseSucceed` — a MISSING token's absence signal is a SUCCESSFUL read with `expire`/
150
+ * `privsep` both absent (`attributesOf`/api-token-form.ts, MEASURED pre-migration), unlike
151
+ * user.ts/group.ts's missing-object 500 — so this family never needed their folding split. A
152
+ * THROWN failure here is genuinely unexpected and must propagate and fail the plan loudly.
153
+ */
154
+ const readToken = (props: ApiTokenProps) => {
155
+ const label = { tokenid: props.tokenid, userid: props.userid };
156
+ return readOrUnreadable(
157
+ runPve(props.target, 'provision', false, access.getAccessUserToken(label)),
158
+ ).pipe(Effect.map((live) => (live === UNREADABLE ? UNREADABLE : attributesOf(live, props))));
159
+ };
150
160
 
151
161
  /** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
152
162
  const dropUnreadable = (live: ApiTokenAttributes | Unreadable | undefined) =>
@@ -193,12 +203,10 @@ export const ProxmoxApiTokenProvider = () =>
193
203
  }),
194
204
  reconcile: Effect.fn(function* ({ news }) {
195
205
  // ⚠️ `dropUnreadable`: reconcile only runs once `provision` already minted for the
196
- // read that fed `diff`'s `update` verdict, so `UNREADABLE` here is a narrow race —
197
- // but UNLIKE group/role/user, the message below assumes genuine absence and would
198
- // misname a transient credential refusal as "this resource does not create tokens".
199
- // Rare (the SAME role must mint twice, once for diff/once for reconcile, and only the
200
- // second is denied) and not silently wrong — the operator still sees a die() and goes
201
- // looking — but named here rather than left for the next reader to assume away.
206
+ // read that fed `diff`'s `update` verdict, so `UNREADABLE` here is a narrow race, not
207
+ // the routine case `diff` handles. Any OTHER read failure now propagates on its own
208
+ // (readToken no longer folds it), so the die() message below is reached only on a
209
+ // genuinely absent token, never on a transient failure wearing that message.
202
210
  const before = dropUnreadable(yield* readToken(news));
203
211
  if (before === undefined) {
204
212
  return yield* Effect.die(
@@ -1,10 +1,13 @@
1
1
  /**
2
- * `Proxmox.Group`'s wire shape: PVE's create/update forms and how a live item becomes
3
- * attributes. Split out of group.ts (2026-09-24) to keep that file under the 250-line cap —
4
- * the api-token.ts/api-token-form.ts seam, same reasoning as acl.ts/acl-wire.ts.
2
+ * `Proxmox.Group`'s wire shape: PVE's create/update forms, how a live item becomes attributes,
3
+ * and the read itself. Split out of group.ts (2026-09-24) to keep that file under the 250-line
4
+ * cap — the api-token.ts/api-token-form.ts seam, same reasoning as acl.ts/acl-wire.ts.
5
5
  */
6
- import type * as access from '@distilled.cloud/proxmox/access';
6
+ import * as access from '@distilled.cloud/proxmox/access';
7
+ import * as Effect from 'effect/Effect';
8
+ import { runPve } from './distilled-pve.ts';
7
9
  import type { GroupAttributes, GroupProps } from './group.ts';
10
+ import { UNREADABLE, type Unreadable, readOrUnreadable } from './unreadable-read.ts';
8
11
  import { csv, text } from './values.ts';
9
12
 
10
13
  export const GROUP_CREATE = 'pve:POST /access/groups';
@@ -41,10 +44,7 @@ export const updateForm = (props: GroupProps): access.PutAccessGroupRequest => (
41
44
  groupid: props.groupid,
42
45
  });
43
46
 
44
- export const attributesOf = (
45
- live: access.GetAccessGroupResponse,
46
- props: GroupProps,
47
- ): GroupAttributes => ({
47
+ const attributesOf = (live: access.GetAccessGroupResponse, props: GroupProps): GroupAttributes => ({
48
48
  /**
49
49
  * ⚠️ ABSENT IS EMPTY, NOT MISSING — `read_group` sets `comment` only `if defined(…)`. distilled
50
50
  * types it `comment?: string`, so `text()`'s `''` fallback meets the same normalisation.
@@ -61,3 +61,57 @@ export const attributesOf = (
61
61
  */
62
62
  export const matches = (attributes: GroupAttributes, props: GroupProps) =>
63
63
  attributes.comment === storedComment(props.comment);
64
+
65
+ /**
66
+ * ★ Default `read` role — nothing widened for this family, see group.ts's header privileges note.
67
+ * ⛔ `{ groupid: props.groupid }`, NEVER THE WHOLE `props` — MEASURED 2026-09-24, the regression
68
+ * in kit 0.31.1. `GetAccessGroupRequest`'s schema declares only `groupid` (a path label); any
69
+ * OTHER key on the object passed to `access.getAccessGroup` — `target`, `comment`, every field
70
+ * this family's own props carry — is treated by distilled's `buildRequest` as an "unknown key"
71
+ * and JSON-encoded onto the request as a BODY, on every one of these bodyless GETs. A stricter
72
+ * fetch client than a bare `bun run` of this file refuses a GET carrying any body at all
73
+ * (`TypeError [ERR_INVALID_ARG_VALUE]: fetch() request with GET/HEAD method cannot have body`),
74
+ * which `runPveWith` retries across every member and exhausts identically —
75
+ * `PveClusterExhausted`, then silently folded to "absent" by the old `orElseSucceed`, forcing a
76
+ * false `update` on every group. `role.ts`'s list call and acl.ts's `listAccessAcl({})` were
77
+ * never at risk: both are always called with a literal `{}`.
78
+ * ⛔ `live.members === undefined` IS DEFENSIVE, NOT THE ABSENCE SIGNAL — CORRECTED 2026-09-24.
79
+ * This file previously claimed a missing group answers 200 with `{"data":null}`, unwrapped to
80
+ * `{}`. MEASURED against the live cluster: `GET /access/groups/{missing}` answers a genuine 500
81
+ * ("group '…' does not exist"), the SAME shape as user.ts's own measured 500 — there is no
82
+ * success-path absence signal here at all. The check stays as insurance against a future PVE
83
+ * release that does answer `{}`, but this function is what actually carries the "not found"
84
+ * case, as a thrown error — `readGroup` below is the one that folds it.
85
+ */
86
+ export const readGroupOrFail = (props: GroupProps) =>
87
+ readOrUnreadable(
88
+ runPve(props.target, 'read', false, access.getAccessGroup({ groupid: props.groupid })),
89
+ ).pipe(
90
+ Effect.map((live) =>
91
+ live === UNREADABLE
92
+ ? UNREADABLE
93
+ : live.members === undefined
94
+ ? undefined
95
+ : attributesOf(live, props),
96
+ ),
97
+ );
98
+
99
+ /**
100
+ * ⛔ FOLDS A GENUINE READ FAILURE TO `undefined` TOO, AND ONLY `read`/`reconcile` MAY USE IT.
101
+ * PVE's OWN missing-group answer is a thrown 500 (the ⛔ above), so `reconcile`'s create branch
102
+ * NEEDS "absent" from a failure to ever run at all — without this fold, a brand-new group could
103
+ * never be created, because its very-first read would always fail loudly. Safe here because a
104
+ * wrongful fold costs at most a redundant `createAccessGroup` POST, which PVE refuses loudly
105
+ * ("group already exists") rather than silently corrupting anything.
106
+ * ⛔ `group.ts`'s `diff` does **NOT** use this — found 2026-09-24, the SAME bug class as the
107
+ * credential denial fix: folding a TRANSIENT failure (a `PveClusterExhausted`, the encoding bug
108
+ * above) into "absent" at PLAN TIME is exactly what forced a false `update` with nothing
109
+ * compared. `diff` calls `readGroupOrFail` directly instead, so a genuine failure there
110
+ * propagates and fails the whole plan loudly — never silently misreports one row.
111
+ */
112
+ export const readGroup = (props: GroupProps) =>
113
+ readGroupOrFail(props).pipe(Effect.orElseSucceed(() => undefined));
114
+
115
+ /** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
116
+ export const dropUnreadable = (live: GroupAttributes | Unreadable | undefined) =>
117
+ live === UNREADABLE ? undefined : live;
@@ -76,19 +76,16 @@ import { guardWrite } from './distilled-guard.ts';
76
76
  import {
77
77
  GROUP_CREATE,
78
78
  GROUP_UPDATE,
79
- attributesOf,
80
79
  createForm,
80
+ dropUnreadable,
81
81
  matches,
82
+ readGroup,
83
+ readGroupOrFail,
82
84
  updateForm,
83
85
  } from './group-wire.ts';
84
86
  import type { PveRequirements, WithTarget } from './resource-spec.ts';
85
87
  import { runPve } from './distilled-pve.ts';
86
- import {
87
- UNREADABLE,
88
- type Unreadable,
89
- readOrUnreadable,
90
- unreadableWarning,
91
- } from './unreadable-read.ts';
88
+ import { UNREADABLE, unreadableWarning } from './unreadable-read.ts';
92
89
 
93
90
  export interface GroupProps extends WithTarget {
94
91
  /**
@@ -127,31 +124,6 @@ export const ProxmoxGroup = Resource<ProxmoxGroup>('Proxmox.Group', {
127
124
  defaultRemovalPolicy: 'retain',
128
125
  });
129
126
 
130
- /**
131
- * ★ Default `read` role — nothing widened for this family, see the header's privileges note.
132
- * ⛔ `live.members === undefined` MEANS ABSENT, NOT AN EMPTY GROUP. `@distilled.cloud/core`'s
133
- * REST protocol unwraps PVE's `{"data": null}` (a genuinely missing object — MEASURED in
134
- * `protocol.ts`: "a Unit-output endpoint... `data` is null/absent") to `{}` BEFORE the
135
- * schema-driven decode, not to a decode failure. `members` is the one REQUIRED field on this
136
- * response, so its absence is the only signal that the group was never there — the same trap
137
- * api-token-form.ts's `attributes` guards with its own required-field check.
138
- */
139
- const readGroup = (props: GroupProps) =>
140
- readOrUnreadable(runPve(props.target, 'read', false, access.getAccessGroup(props))).pipe(
141
- Effect.map((live) =>
142
- live === UNREADABLE
143
- ? UNREADABLE
144
- : live.members === undefined
145
- ? undefined
146
- : attributesOf(live, props),
147
- ),
148
- Effect.orElseSucceed(() => undefined),
149
- );
150
-
151
- /** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
152
- const dropUnreadable = (live: GroupAttributes | Unreadable | undefined) =>
153
- live === UNREADABLE ? undefined : live;
154
-
155
127
  export const ProxmoxGroupProvider = () =>
156
128
  Provider.effect(
157
129
  ProxmoxGroup,
@@ -162,15 +134,31 @@ export const ProxmoxGroupProvider = () =>
162
134
  * cluster, `admins` (grants `Administrator` on `/`) included. Adoption stays explicit.
163
135
  */
164
136
  list: () => Effect.succeed([]),
165
- read: Effect.fn(function* ({ olds }) {
166
- return dropUnreadable(yield* readGroup(olds));
137
+ // ⚠️ FOLDS ONLY WHEN `output` IS `undefined` — MEASURED 2026-09-24, an adversarial review
138
+ // of this very fix caught the gap. The engine calls this ONE hook from FOUR places
139
+ // (alchemy/src/{Plan,Apply,Drift}.ts), and only `output` tells them apart: Plan.ts's
140
+ // cold-start adoption probe and interrupted-create recovery, and Apply.ts's
141
+ // delete-recovery, all pass `output: undefined` — nothing is confirmed to exist yet, so
142
+ // `readGroup`'s fold is still needed there or a brand-new group could never be adopted
143
+ // or created. `Drift.ts` (`alchemy drift`/`sync`/`deploy --detect-drift`) is different:
144
+ // it calls this on an ALREADY-CONFIRMED row (`output: old.attr`, defined), exactly
145
+ // `diff`'s own situation below — a transient failure folded to `undefined` there is
146
+ // reported as `{action: 'missing'}` with NO error at all, the cries-wolf bug again, one
147
+ // command over. `readGroupOrFail` on that branch propagates it instead.
148
+ read: Effect.fn(function* ({ olds, output }) {
149
+ return dropUnreadable(
150
+ yield* output === undefined ? readGroup(olds) : readGroupOrFail(olds),
151
+ );
167
152
  }),
168
153
  diff: Effect.fn(function* ({ news, output }) {
169
154
  if (!isResolved(news)) return undefined;
170
155
  yield* guardWrite(GROUP_CREATE, createForm(news), output === undefined);
171
156
  yield* guardWrite(GROUP_UPDATE, updateForm(news), false);
172
157
  if (output === undefined) return undefined;
173
- const live = yield* readGroup(news);
158
+ // ⚠️ `readGroupOrFail`, NOT `readGroup` — see that function's own ⛔. A genuine
159
+ // TRANSIENT failure here propagates and fails the whole plan loudly instead of
160
+ // folding to "absent" and forcing a false update, the cries-wolf class of bug.
161
+ const live = yield* readGroupOrFail(news);
174
162
  // ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and force
175
163
  // `update` on a group that was plainly there — see unreadable-read.ts.
176
164
  if (live === UNREADABLE) {