@homeflare/alchemy 0.37.0 → 0.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/proxmox/ceph-daemon-wire.d.ts +5 -7
  2. package/dist/proxmox/ceph-daemon-wire.d.ts.map +1 -1
  3. package/dist/proxmox/ceph-daemon.d.ts +7 -8
  4. package/dist/proxmox/ceph-daemon.d.ts.map +1 -1
  5. package/dist/proxmox/ceph-fs-distilled.d.ts +24 -5
  6. package/dist/proxmox/ceph-fs-distilled.d.ts.map +1 -1
  7. package/dist/proxmox/ceph-fs.d.ts +4 -5
  8. package/dist/proxmox/ceph-fs.d.ts.map +1 -1
  9. package/dist/proxmox/ceph-pool-settle.d.ts +9 -8
  10. package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
  11. package/dist/proxmox/ceph-pool-wire.d.ts +11 -20
  12. package/dist/proxmox/ceph-pool-wire.d.ts.map +1 -1
  13. package/dist/proxmox/group-wire.d.ts +7 -12
  14. package/dist/proxmox/group-wire.d.ts.map +1 -1
  15. package/dist/proxmox/index.js +7 -19
  16. package/dist/proxmox/index.js.map +13 -13
  17. package/dist/proxmox/storage-wire.d.ts +7 -10
  18. package/dist/proxmox/storage-wire.d.ts.map +1 -1
  19. package/dist/proxmox/unreadable-read.d.ts +7 -7
  20. package/dist/proxmox/user-wire.d.ts +7 -10
  21. package/dist/proxmox/user-wire.d.ts.map +1 -1
  22. package/dist/proxmox/user.d.ts +5 -5
  23. package/docs/distilled-interim.md +15 -3
  24. package/docs/provider-standard.md +24 -6
  25. package/docs/proxmox.md +20 -0
  26. package/docs/upstream-conformance-bun.md +67 -0
  27. package/docs/upstream-conformance-conforms.md +55 -0
  28. package/docs/upstream-conformance-families.md +95 -0
  29. package/docs/upstream-conformance-grafana.md +106 -0
  30. package/docs/upstream-conformance.md +20 -298
  31. package/package.json +1 -1
  32. package/src/proxmox/ceph-daemon-wire.ts +4 -7
  33. package/src/proxmox/ceph-daemon.ts +7 -8
  34. package/src/proxmox/ceph-fs-distilled.ts +7 -9
  35. package/src/proxmox/ceph-fs.ts +8 -10
  36. package/src/proxmox/ceph-pool-settle.ts +11 -10
  37. package/src/proxmox/ceph-pool-wire.ts +11 -20
  38. package/src/proxmox/ceph-pool-write.test.ts +9 -8
  39. package/src/proxmox/group-wire.ts +7 -12
  40. package/src/proxmox/read-failure-propagation.test.ts +5 -5
  41. package/src/proxmox/storage-wire.ts +9 -10
  42. package/src/proxmox/typed-absence.test.ts +146 -0
  43. package/src/proxmox/unreadable-read.ts +7 -7
  44. package/src/proxmox/user-wire.ts +7 -10
  45. package/src/proxmox/user.ts +5 -5
@@ -42,17 +42,14 @@ export declare const same: <T>(declared: T | undefined, live: T) => boolean;
42
42
  */
43
43
  export declare const readStorageOrFail: (props: StorageProps) => Effect.Effect<typeof UNREADABLE | StorageAttributes, 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 | storage.StorageNotFound | 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>;
44
44
  /**
45
- * ⛔ FOLDS A GENUINE READ FAILURE TO `undefined` TOO, AND ONLY `read`/`reconcile` MAY USE IT —
46
- * user.ts's `readUser` has the full reasoning. Safe here because a wrongful fold costs at most a
47
- * redundant `createStorage` POST, which PVE refuses loudly ("storage ID already exists") rather
48
- * than silently corrupting anything.
49
- * ⛔ `storage.ts`'s `diff` does **NOT** use this — the SAME bug class as the credential denial fix:
50
- * folding a TRANSIENT failure into "absent" at PLAN TIME is what forced a false `update` with
51
- * nothing compared (the ORIGINAL cries-wolf bug this family shipped with, per unreadable-read.ts's
52
- * own header). `diff` calls `readStorageOrFail` directly, so a genuine failure there propagates
53
- * and fails the whole plan loudly instead.
45
+ * ⛔ ONLY `StorageNotFound` MEANS ABSENT. The initial distilled transport swap preserved a
46
+ * catch-all fold because PVE reports missing objects as HTTP 500. SDK PR 265 now types that
47
+ * measured message, so permission errors, unrelated 500s and exhausted transports propagate
48
+ * instead of triggering a speculative create (decision 49 follow-up, 2026-09-24).
49
+ * ★ `readStorageOrFail` remains the strict path used for an already-confirmed state row. This
50
+ * change narrows cold reads/reconcile without changing that existing drift/diff contract.
54
51
  */
55
- export declare const readStorage: (props: StorageProps) => Effect.Effect<typeof UNREADABLE | StorageAttributes | undefined, never, import("effect/unstable/http/HttpClient").HttpClient>;
52
+ export declare const readStorage: (props: StorageProps) => Effect.Effect<typeof UNREADABLE | StorageAttributes | undefined, 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>;
56
53
  /** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
57
54
  export declare const dropUnreadable: (live: StorageAttributes | Unreadable | undefined) => StorageAttributes | undefined;
58
55
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"storage-wire.d.ts","sourceRoot":"","sources":["../../src/proxmox/storage-wire.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,OAAO,MAAM,kCAAkC,CAAC;AAC5D,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAExC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AACjD,OAAO,EAAE,UAAU,EAAE,KAAK,UAAU,EAAoB,MAAM,sBAAsB,CAAC;AAGrF,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,MAAM,CAAC;IAChB,6FAA6F;IAC7F,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,eAAe,EAAE,MAAM,CAAC;IACxB,OAAO,EAAE,MAAM,CAAC;CACjB;AAmBD,eAAO,MAAM,QAAQ,aAAc,MAAM,GAAG,SAAS,QAAQ,MAAM,YACN,CAAC;AAE9D,eAAO,MAAM,IAAI,GAAI,CAAC,YAAY,CAAC,GAAG,SAAS,QAAQ,CAAC,YACX,CAAC;AAwB9C;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,iBAAiB,UAAW,YAAY,63BAGyC,CAAC;AAE/F;;;;;;;;;;GAUG;AACH,eAAO,MAAM,WAAW,UAAW,YAAY,kIACuB,CAAC;AAEvE,gGAAgG;AAChG,eAAO,MAAM,cAAc,SAAU,iBAAiB,GAAG,UAAU,GAAG,SAAS,kCACvC,CAAC;AAEzC;;;GAGG;AACH,eAAO,MAAM,OAAO,eAAgB,iBAAiB,SAAS,YAAY,YAOjC,CAAC"}
1
+ {"version":3,"file":"storage-wire.d.ts","sourceRoot":"","sources":["../../src/proxmox/storage-wire.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,OAAO,MAAM,kCAAkC,CAAC;AAC5D,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAExC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AACjD,OAAO,EAAE,UAAU,EAAE,KAAK,UAAU,EAAoB,MAAM,sBAAsB,CAAC;AAGrF,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,MAAM,CAAC;IAChB,6FAA6F;IAC7F,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,eAAe,EAAE,MAAM,CAAC;IACxB,OAAO,EAAE,MAAM,CAAC;CACjB;AAmBD,eAAO,MAAM,QAAQ,aAAc,MAAM,GAAG,SAAS,QAAQ,MAAM,YACN,CAAC;AAE9D,eAAO,MAAM,IAAI,GAAI,CAAC,YAAY,CAAC,GAAG,SAAS,QAAQ,CAAC,YACX,CAAC;AAwB9C;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,iBAAiB,UAAW,YAAY,63BAGyC,CAAC;AAE/F;;;;;;;GAOG;AACH,eAAO,MAAM,WAAW,UAAW,YAAY,+2BAG5C,CAAC;AAEJ,gGAAgG;AAChG,eAAO,MAAM,cAAc,SAAU,iBAAiB,GAAG,UAAU,GAAG,SAAS,kCACvC,CAAC;AAEzC;;;GAGG;AACH,eAAO,MAAM,OAAO,eAAgB,iBAAiB,SAAS,YAAY,YAOjC,CAAC"}
@@ -18,10 +18,10 @@
18
18
  * on a lane that cannot prove one is needed and (b) never takes the rest of the plan down with
19
19
  * it — the "noop-with-warning" reading of the fix, over the alternative of a typed plan error.
20
20
  *
21
- * ⛔ ONLY THE CREDENTIAL DENIAL IS CAUGHT HERE. Every other read failure (a genuine
22
- * `Cause.TimeoutError`, `PveClusterExhausted`, a network error) keeps folding into `undefined`
23
- * exactly as it always has — this narrows ONE typed tag out of that fold, via `catchTag`, and
24
- * changes nothing else about it. See credential-errors.ts's own ⛔ for why 403 alone qualifies.
21
+ * ⛔ ONLY THE CREDENTIAL DENIAL IS CAUGHT HERE. Other errors propagate to the caller.
22
+ * Legacy callers may still fold them, but the typed-absence follow-up makes User/Group/
23
+ * Storage catch only their SDK missing-object tag. Never infer a resource is absent from
24
+ * this sentinel. See credential-errors.ts's own ⛔ for why the OpenBao mint's 403 qualifies.
25
25
  */
26
26
  import * as Effect from 'effect/Effect';
27
27
  import type { PveCredentialDenied } from './credential-errors.ts';
@@ -29,9 +29,9 @@ import type { PveCredentialDenied } from './credential-errors.ts';
29
29
  export declare const UNREADABLE: unique symbol;
30
30
  export type Unreadable = typeof UNREADABLE;
31
31
  /**
32
- * Catches ONLY `PveCredentialDenied` and turns it into the sentinel, so a caller that also
33
- * `.pipe(Effect.orElseSucceed(() => undefined))` for every OTHER failure never conflates a
34
- * refusal with a genuine absence. Compose this INSIDE that fold, never after it.
32
+ * Catches ONLY `PveCredentialDenied` and returns the sentinel. Compose this BEFORE the
33
+ * caller's absence handling so a credential refusal cannot become a genuine absence.
34
+ * This helper itself classifies neither SDK missing-object tags nor transport failures.
35
35
  *
36
36
  * ⚠️ `E | PveCredentialDenied` IN, `E` OUT — not a generic `E` narrowed by `Exclude`. `catchTag`
37
37
  * needs the tag to be STATICALLY present in the error union to typecheck at all; a bare
@@ -31,17 +31,14 @@ export declare const matches: (attributes: UserAttributes, props: UserProps) =>
31
31
  */
32
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 | access.UserNotFound, import("effect/unstable/http/HttpClient").HttpClient>;
33
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.
34
+ * ⛔ ONLY `UserNotFound` MEANS ABSENT. The initial distilled transport swap preserved a
35
+ * catch-all fold because PVE reports missing objects as HTTP 500. SDK PR 265 now types that
36
+ * measured message, so permission errors, unrelated 500s and exhausted transports propagate
37
+ * instead of triggering a speculative create (decision 49 follow-up, 2026-09-24).
38
+ * ★ `readUserOrFail` remains the strict path used for an already-confirmed state row. This
39
+ * change narrows cold reads/reconcile without changing that existing drift/diff contract.
43
40
  */
44
- export declare const readUser: (props: UserProps) => Effect.Effect<typeof UNREADABLE | UserAttributes | undefined, never, import("effect/unstable/http/HttpClient").HttpClient>;
41
+ export declare const readUser: (props: UserProps) => Effect.Effect<typeof UNREADABLE | UserAttributes | undefined, 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>;
45
42
  /** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
46
43
  export declare const dropUnreadable: (live: UserAttributes | Unreadable | undefined) => UserAttributes | undefined;
47
44
  //# 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,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,s3BAG+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"}
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,s3BAG+C,CAAC;AAE/F;;;;;;;GAOG;AACH,eAAO,MAAM,QAAQ,UAAW,SAAS,42BACqD,CAAC;AAE/F,gGAAgG;AAChG,eAAO,MAAM,cAAc,SAAU,cAAc,GAAG,UAAU,GAAG,SAAS,+BACpC,CAAC"}
@@ -34,11 +34,11 @@
34
34
  * · `Sys.Audit` (or `User.Modify`) for the read, which the `read` role's auditor already has.
35
35
  *
36
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.
37
+ * ('x@pve')". The first SDK migration exposed it as `InternalServerError`, so its create
38
+ * workflow needed a catch-all fold. SDK PR #265 now recognizes `UserNotFound`; `readUser`
39
+ * catches only that tag, letting other cold-read/reconcile failures propagate. The strict
40
+ * `readUserOrFail` path for confirmed rows remains unchanged: its original transient-failure
41
+ * fix stopped the cries-wolf update with nothing compared (user-wire.ts).
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 —
@@ -34,9 +34,21 @@ changes and nothing else does.
34
34
  Follow `.agents/skills/distilled-sdk/SKILL.md` in the local distilled clone —
35
35
  Steps 1–8 (never Step 9, which pushes). One worktree per vendor, branch
36
36
  `homeflare/<vendor>`, off `homeflare/base`. Spec sourcing, `patches/`,
37
- `convert.ts`/`generate.ts`, typecheck, `pnpm format`, `pnpm specs:check`.
38
- Commit in the local clone only — ⛔ **never push it anywhere; no forks, no
39
- PRs, no issues or comments on `alchemy-run/distilled` from this route.**
37
+ `convert.ts`/`generate.ts`, `pnpm typecheck:ci` (never plain `typecheck`,
38
+ which skips generated code — `noCheck: true`), `pnpm format`,
39
+ `pnpm specs:check`. Commit in the local clone only — ⛔ **never push it
40
+ anywhere; no forks, no PRs, no issues or comments on `alchemy-run/distilled`
41
+ from this route.**
42
+
43
+ ⚠️ **A vendor with no machine-readable spec still gets a `SPEC_REPOS` entry**
44
+ (`stacks/distilled-submodules/SpecRepos.ts`) with `blocked` and a reason. Upstream's
45
+ Slack entry is the precedent. A blocked entry still declares the mirror repository,
46
+ but skips fetch scaffolding; it does not pretend there is a spec to fetch.
47
+ Caddy follows it (Q7, decision 49, 2026-09-24): the local `homeflare/caddy` worktree's
48
+ `packages/caddy/docs/provenance.md` records the admin-API spec search against Caddy
49
+ 2.11.4's Go source and website. Its admin-operation model is hand-authored, and its
50
+ `SpecRepos.ts` entry records that reason. The separately discovered config-tree
51
+ metadata is not an admin-operation specification.
40
52
 
41
53
  ### 2. Copy into a kit workspace package
42
54
 
@@ -1,7 +1,11 @@
1
1
  # The provider standard
2
2
 
3
- Status: in force. Verified 2026-09-22 against `alchemy@2.0.0-beta.79` (tag
3
+ Status: in force. Verified 2026-09-24 against `alchemy@2.0.0-beta.79` (tag
4
4
  `v2.0.0-beta.79`, commit `473c3959`) and the distilled commit that tag pins (`c2a78002`).
5
+ Decision 49 ("upstream wins", 2026-09-24): where upstream has a rule or clear convention, the
6
+ house follows it exactly, neither looser nor stricter; a house rule only fills a gap upstream
7
+ is silent on. This update corrects S20 and clarifies S21 against the pinned source; it
8
+ does not claim a new audit of every rule or family.
5
9
 
6
10
  This page is the kit-side statement of the house standard for a custom Alchemy provider.
7
11
  The full rule set is the `alchemy-provider-standard` skill in the estate's workflow plugin.
@@ -64,11 +68,25 @@ already ships is a finding, even when it works.
64
68
  CPU-only Node call (`node:crypto` `createHash`, `Buffer`) goes inside `Effect.sync`, which
65
69
  is upstream's own example. When a promise cannot be avoided, use `Effect.tryPromise`,
66
70
  never `Effect.promise`.
67
- - **No defects (S20).** Lifecycle operations contain no `Effect.orDie` and no
68
- `Effect.die`. A refusal is a typed error.
69
- - **Typed errors (S21, S22).** Handle errors with `Effect.catchTag` over the SDK's union.
70
- Where the kit generates its own client from a vendor schema, the status-to-tag mapping
71
- lives in that client, once, and never in a resource.
71
+ - **Lifecycle errors (S20).** Upstream's rule is: "Do not use `Effect.orDie` in the
72
+ lifecycle operations since this will crash the whole IaC engine."
73
+ Cite: `AGENTS.md@v2.0.0-beta.79#L630`. It names `Effect.orDie` in lifecycle operations;
74
+ S20 adds no separate ban on `Effect.die` or on code outside those operations.
75
+ `Effect.die` still creates a defect. The engine's narrow recovery exception is the
76
+ best-effort read for an interrupted create: `Effect.catchDefect` degrades a crashed
77
+ recovery read to nothing recovered (`packages/alchemy/src/Plan.ts@tag#L1447`,
78
+ `packages/alchemy/src/Apply.ts@tag#L2218`). Ordinary lifecycle calls have no such
79
+ recovery wrapper (`Plan.ts@tag#L1303-L1315`); this is not a general safe-defect path.
80
+ Expected refusals stay typed errors.
81
+ - **Typed errors (S21, S22).** Handle errors with `Effect.catchTag` over the SDK's union;
82
+ other typed SDK errors can propagate unchanged, as upstream's R2 `BucketSippy` does.
83
+ The resource-specific-tag patch rule applies to errors **outside** that typed union
84
+ (`AGENTS.md@tag#L733-L738`). Where a package's protocol includes status classes in
85
+ its union (argocd, hetzner, fly-io and neon at distilled `c2a78002`), catch those tags
86
+ directly: upstream `Hetzner/Certificate.ts@tag#L251,L288-L291` catches `NotFound`
87
+ for absent reads and successful deletes. No replacement tag is needed for an already
88
+ typed `NotFound`. Where the kit generates its own client from a vendor schema, the
89
+ status-to-tag mapping lives in that client, once, and never in a resource.
72
90
  - **Credentials (S24).** Credentials come from an `alchemy/Auth` provider and a lazy
73
91
  Credentials service. Short-lived credentials are minted per call, never reused, and never
74
92
  taken as props.
package/docs/proxmox.md CHANGED
@@ -21,3 +21,23 @@ or creates one from a template.
21
21
  - ★ **Measurements in the source name a reference cluster with placeholders**: `C1`, three nodes
22
22
  `node-b`, `node-c` and `node-d` with Ceph on a Thunderbolt mesh, and documentation addresses
23
23
  (RFC 5737). The shapes and counts are measured; the names and addresses are not the real ones.
24
+
25
+ ## Distilled read failures (2026-09-24)
26
+
27
+ The SDK walk-down in [PR #265](https://github.com/taslabs-net/homeflare-kit/pull/265)
28
+ added vendor-evidenced `UserNotFound`, `GroupNotFound`, `StorageNotFound` and
29
+ `CephPoolNotFound` tags for PVE's missing-object HTTP 500s. The provider now catches
30
+ those tags on cold User/Group/Storage reads and CephPool reads. Other SDK errors
31
+ propagate unchanged; a failed CephFS or mon/mgr/mds index also fails rather than
32
+ proving absence. Existing strict confirmed-row User/Group/Storage reads remain strict.
33
+
34
+ `typed-absence.test.ts` drives the real SDK protocol through fake transport, then proves
35
+ with Alchemy's engine that a failed cold read cannot send a create and an unreadable
36
+ CephFS index cannot produce a successful delete. Existing adoption fixtures prove
37
+ matching objects remain no-ops. Credential-denied warnings are still not live no-op
38
+ evidence; use the consumer's authorized read lane and `hf-adopt-verify --all`.
39
+
40
+ This is a scoped follow-up to the transport migration. CephFS delete still uses the old
41
+ client despite the SDK's now-fixed DELETE query encoding; bounded task polling and
42
+ other pre-existing provider refusals retain their own policies. No live writes are part
43
+ of this validation. Released-consumer plans remain a separate gate from these fixtures.
@@ -0,0 +1,67 @@
1
+ # The Bun line
2
+
3
+ Status: extracted from the [conformance ledger](./upstream-conformance.md); its dated
4
+ measurements and open findings are preserved below.
5
+
6
+ These are non-test files under `src/` that the standard (S42) says must be portable. "Exp"
7
+ marks files exported from a subpath `index.ts`. `git grep` on `origin/main` finds 23 non-test
8
+ files under `src/` that call `Bun.*` or import `node:*`/`bun:*`. Six of them are outside
9
+ S42's scope:
10
+
11
+ - four loopback fakes (`caddy/fake-caddy.ts`, `openbao/fake-bao.ts`, `proxmox/fake-pve.ts`,
12
+ `proxmox/fake-pve-lxc.ts`), which are test-side under S44;
13
+ - `proxmox/provision-cli-fake.ts`, which is used only by tests;
14
+ - `verify/args.ts`, which belongs to the CLI and so is tooling under S43.
15
+
16
+ That leaves 17 files. The table lists them, plus `provision-cli-fake.ts`, which should move
17
+ out of `src/`. The six out-of-scope files still ship in the tarball's `src/`, but no export
18
+ reaches them.
19
+ ⚠️ The first version of this table said 16 files and left out `launchd/job-form.ts`.
20
+ ⚠️ Corrected 2026-09-22: 14 of the 17 break upstream's rule. `launchd/job-form.ts`,
21
+ `proxmox/write-only.ts` and `proxmox/pbs-notification-target-wire.ts` use only synchronous
22
+ `node:crypto` or `Buffer`. Upstream allows those inside `Effect.sync`
23
+ (`AGENTS.md@v2.0.0-beta.79#Workflow`), and 40 upstream `src/` files import `node:crypto`,
24
+ among them `Fly/Secret.ts` and `Railway/Variable.ts`. They were listed under a blanket
25
+ `node:*` ban that upstream does not have.
26
+
27
+ | file | API | exp | portable replacement |
28
+ | ---------------------------------------------------------- | -------------------------------------------- | --- | -------------------------------------------- |
29
+ | `openbao/cloudflare-roles-config.ts` | `Bun.YAML` | yes | parse on the tooling side; pass data in |
30
+ | `openbao/digest.ts` (14 importers) | `Bun.CryptoHasher` | — | `Crypto.digest` + hex; keep `canonical()` ⚠️ |
31
+ | `openbao/cloudflare-parity-snapshot.ts` | `Bun.file`, `Bun.Glob` | — | `FileSystem` + `Path` |
32
+ | `openbao/approle-login-form.ts` | `Bun.file` | — | `FileSystem.readFileString` |
33
+ | `openbao/approle-login-result.ts` | `Bun.inspect` | — | a plain formatter |
34
+ | `openbao/forgejo-bootstrap.ts` | `Bun.spawn` | — | unreferenced; `ChildProcessSpawner` or drop |
35
+ | `talos/credentials.ts` | `Bun.write/file/env/randomUUIDv7`, `node:fs` | — | `FileSystem.makeTempFileScoped` |
36
+ | `talos/kubeconfig.ts` | `Bun.file` | yes | `FileSystem` |
37
+ | `talos/talos-machine-config.ts` | `Bun.file` | yes | `FileSystem` |
38
+ | `talos/values.ts` | `Bun.YAML`, `node:crypto` | — | tooling-side parse; hash may stay |
39
+ | `launchd/local-runner.ts` | `node:child_process`, `node:fs/promises` | yes | H3 above |
40
+ | `launchd/sudo-stage.ts` | `node:fs/promises`, `node:os`, `node:path` | — | `FileSystem`, `Path` |
41
+ | `launchd/job-form.ts` | `node:crypto` (`createHash`) | — | allowed in `Effect.sync` |
42
+ | `linux/ssh-runner.ts` | `node:child_process`, `node:crypto` | yes | H3 above |
43
+ | `caddy/caddy-http-client.ts` | `node:http`, `node:stream` (unix socket) | yes | inherent — an Effect `HttpClient`, not H3 ⚠️ |
44
+ | `proxmox/write-only.ts`, `pbs-notification-target-wire.ts` | `node:crypto`, `node:buffer` | — | allowed in `Effect.sync` ⚠️ |
45
+ | `proxmox/provision-cli-fake.ts` | `Bun.spawn`, `node:fs` | — | test-only; move out of `src/` |
46
+
47
+ The ⚠️ rows:
48
+
49
+ - `openbao/digest.ts`: every `Bao.*` family persists this digest in state, so a swap must
50
+ hash the same bytes (`canonical()`, lowercase hex), or every row plans an update.
51
+ `Crypto.digest` from `effect/Crypto` is in every stack's services and fails with a typed
52
+ error; `alchemy/Util/sha256` wraps WebCrypto in `Effect.promise`.
53
+ - `proxmox/write-only.ts`: a salted scrypt seal with `timingSafeEqual`. No Alchemy helper
54
+ replaces it. A plain `sha256` would make a write-only secret cheap to brute-force from
55
+ state, and it would change the persisted seal format.
56
+ - `caddy/caddy-http-client.ts`: unlike `launchd/local-runner.ts` and `linux/ssh-runner.ts`
57
+ (H3, plain `async` interfaces), this is already the Effect `HttpClient.HttpClient` the
58
+ distilled SDK's own protocol runs on — `node:http` here is the same kind of unavoidable,
59
+ bottom-of-the-stack platform adapter `@effect/platform-node`'s own `NodeHttpClient.ts` is,
60
+ extended with `socketPath` dialing (that module has none) because a unix socket is the
61
+ measured transport a local Caddy needs and neither `FetchHttpClient` (Bun-only `unix`
62
+ option) nor stock `NodeHttpClient` (URL-only) speaks one on both Bun and Node. There is no
63
+ more-portable replacement to point to; the file already conforms to S19 (no async/await,
64
+ no raw `Promise` — `Effect.callback`/`Effect.tryPromise` throughout).
65
+
66
+ Test runners: 51 files import `node:test`/`node:assert` (openbao 42, proxmox 7, forgejo 1,
67
+ talos 1), and 126 import `bun:test`. S43 says `bun:test`.
@@ -0,0 +1,55 @@
1
+ # Upstream conformance: conforming building blocks
2
+
3
+ Status: extracted from the [conformance ledger](./upstream-conformance.md); its dated
4
+ measurements and open findings are preserved below.
5
+
6
+ - **`paperless/*` (`Tag`, `DocumentType`, `StoragePath`, `CustomField`)** — ✅ **S23 fixed
7
+ 2026-09-24** (branch `claude2/distilled-paperless-family`, decision 43, mirroring the
8
+ netbox/forgejo migrations above). Every call now goes through `@distilled.cloud/paperless-ngx`'s
9
+ typed operations, `catchTag('NotFound', …)` replaced the status-carrying `PaperlessError`
10
+ union, and `client.ts`/`errors.ts`/`credentials.ts` are deleted. The shared
11
+ `matching.ts`/`matching-locate.ts` engine (locate-by-name before state, by `output.id` after —
12
+ PR 163) is unchanged in shape. Not published upstream yet, aliased onto
13
+ `@homeflare/distilled-paperless-ngx@0.3.0` — [distilled-interim.md](./distilled-interim.md).
14
+ State did not move: props/attributes stay byte-identical, proven by `tag.test.ts` and the full
15
+ PR 163 regression suite (`tag-identity.test.ts`) against a fake exercising the real distilled
16
+ protocol. ⛔ **Still diverges on credentials (S24):** `PAPERLESS_URL`/`PAPERLESS_TOKEN` are read
17
+ at call time, now through the SDK's `CredentialsFromEnv` — same divergence, unchanged by the
18
+ transport swap.
19
+ - **`github/RepositoryRuleset`** (added 2026-09-23) closes the hazard the row above names:
20
+ probes by name (`read`/`reconcile` both), answers `Unowned` on a cold name match (H1),
21
+ normalizes before comparing so a matching live ruleset is a true noop, and refuses
22
+ (rather than silently drops) a live rule type it does not model. Two recorded
23
+ divergences: the type string sits in upstream's own `GitHub` namespace (H14), and the
24
+ client is Octokit rather than distilled because distilled's `S.Struct` request schemas
25
+ have no field for `require_extra_approval_for_unattributed_changes` (H15) — see
26
+ [repository-ruleset.md](./repository-ruleset.md). `declareRepoPolicy` is rewired onto
27
+ it; the row below is now describing upstream `GitHub.Ruleset` itself, which this family
28
+ does not touch and remains usable directly.
29
+ - **`github/declareRepoPolicy`** composes upstream `GitHub.Repository` and
30
+ `GitHub.Ruleset`. Its one hazard is upstream's own: `Ruleset.read` returns `undefined`
31
+ without prior output, so a first deploy creates a duplicate
32
+ ([repo-policy.md](./repo-policy.md)). The fix belongs upstream: read by name and answer
33
+ `Unowned`. Since 2026-09-23 `declareRepoPolicy` no longer takes this path (see the
34
+ `RepositoryRuleset` row above); this remains accurate for upstream `Ruleset` on its own.
35
+ - **`cloudflare/website.ts`** sets house defaults over upstream `Website.Astro` and
36
+ `Website.Vite`.
37
+ - **`ownership/*`** is built only on `AdoptPolicy`, `Stack`, `State` and `Artifacts`. Its
38
+ apply-time re-check (H2) exists because beta.79 skips the adoption probe while props hold
39
+ an Output. That fix also belongs in the engine upstream.
40
+ - **`verify/*`** (`hf-adopt-verify`) runs Alchemy's own planner. It exists because beta.79
41
+ labels every cold adoption as an update (H6).
42
+ - **`google-workspace/*`** (new 2026-09-24, `Group`, `GroupMember`, `DomainAlias`, `OrgUnit`)
43
+ is built directly on `@distilled.cloud/google-workspace@1.0.0-rc.12`'s typed
44
+ `unstable/admin_directory_v1` operations from the first commit — no hand-rolled client ever
45
+ existed for it to migrate off (S23), so there is no `client.ts` deletion to record. Every
46
+ `fetchLive` is get-by-key with its own `catchTag('NotFound', …)`, the same split forgejo's and
47
+ netbox's engines use (resource.ts). Get-by-key rather than NetBox's list-then-disambiguate,
48
+ since Directory addresses every object this family models by a stable key. ⛔ **Diverges on
49
+ credentials, same shape as the rest of this ledger (S24):** `GOOGLE_ACCESS_TOKEN` is read at
50
+ call time through the SDK's own `CredentialsFromEnv` — but here the SDK provides no way to
51
+ MINT that token at all (no service-account/DWD support), so a Bun wrapper outside this
52
+ package (H8, unwritten by this PR) is load-bearing in a way NetBox's/Forgejo's simple
53
+ API-token env vars are not. SDK gaps (the `unstable/` service tree, no per-operation error
54
+ beyond the shared 4xx set, no group-alias update) and the full credential/scope setup:
55
+ [google-workspace.md](./google-workspace.md).
@@ -0,0 +1,95 @@
1
+ # Upstream conformance: remaining family findings
2
+
3
+ Status: extracted from the [conformance ledger](./upstream-conformance.md); its dated
4
+ measurements and open findings are preserved below.
5
+
6
+ 7. **`proxmox/*` is the model for S38.** Its types and constraints are generated from the
7
+ vendor schema, and a manifest records the version and sha256. It diverges in four ways:
8
+ - the generic factory's `read` returns plain attributes, so every family except `Lxc`
9
+ adopts silently, against the house's own H1;
10
+ - 27 `Effect.die` sites in non-test source (defect risk, not a separate S20 ban);
11
+ - 7 test files on `node:test`;
12
+ - `Pbs.*` type strings (H14).
13
+ 8. **`talos/*`.** Its `talosctl` calls go through `ChildProcessSpawner`, as upstream's Docker
14
+ provider does, which conforms. Its file handling uses `Bun.file`, `Bun.write`,
15
+ `Bun.YAML` and `node:fs` (S42), and it uses `Effect.promise` in 3 files (S19). The
16
+ kubeconfig it writes is meant to be consumed through upstream's
17
+ `Kubernetes.KubeConfig({ path })`.
18
+ 9. **`netbox/*` conforms mostly.** Its constraints are generated from NetBox 4.7.0's
19
+ OpenAPI. Adopting first is documented as deliberate.
20
+ - ✅ **S23 fixed 2026-09-23** (branch `claude2/distilled-netbox-family`, mirroring the
21
+ Forgejo migration above, decision 42). `Netbox.Prefix` now calls
22
+ `@distilled.cloud/netbox`'s typed `ipam` operations instead of a hand-rolled `Effect
23
+ HttpClient` client. The old status-carrying `NetboxError` and its `cause.status ===
24
+ 404` check are gone entirely — `Netbox.Prefix` locates by a server-side list filter,
25
+ which never 404s (an empty page is a normal 200), so nothing here checks a status
26
+ code at all, a stronger form of the same rule `catchTag('NotFound', …)` enforces for
27
+ a family that reads by direct key. `client.ts` is deleted. Not published upstream
28
+ yet, so aliased onto `@homeflare/distilled-netbox@0.2.0` as a plain `dependencies`
29
+ entry, not a peer — [distilled-interim.md](./distilled-interim.md). State did not
30
+ move: props/attributes stay byte-identical, proven by the family's unchanged
31
+ existing tests plus new tests against a fake NetBox exercising the real distilled
32
+ protocol.
33
+ - ⛔ **Still diverges on credentials (S24):** `NETBOX_TOKEN` / `NETBOX_URL` are read at
34
+ call time, now through the SDK's `CredentialsFromEnv` rather than a hand-rolled
35
+ `token()` — same divergence, unchanged by the transport swap.
36
+ 10. **`litellm/*` has no upstream equivalent** (LiteLLM has no upstream Alchemy family, the
37
+ same situation `openbao/*` is in above), and conforms on the transport contract.
38
+ - ✅ **S23 fixed 2026-09-24** (branch `claude2/litellm-distilled`, mirroring the NetBox
39
+ migration above, decision 43). `LiteLLM.PassThroughEndpoint` now calls
40
+ `@distilled.cloud/litellm`'s typed `misc` operations (`operations.ts`) instead of a
41
+ hand-rolled `Effect HttpClient` client. The old status-carrying `LitellmBadRequestError`
42
+ family is gone: every failure the four operations declare (`BadRequest`, `NotFound` on
43
+ update, `UnprocessableEntity`, plus the shared `Unauthorized`/`TooManyRequests`/server
44
+ errors) is the SDK's own typed error, `catchTag`'d — `deletePassThroughEndpoint`'s
45
+ re-list-on-ambiguous-`BadRequest` trick (S21: a status-and-a-real-read decision, never
46
+ body text) is unchanged, now keyed on the tag instead of the status code. The
47
+ per-base-URL write semaphore this vendor's whole-list storage forces (docs/litellm.md)
48
+ is unchanged too — the SDK has no opinion on it, so it stays one layer above the typed
49
+ calls, in `operations.ts`. `client.ts` and the kit's own hand-generated
50
+ `generated/pass-through.ts` are both deleted (the SDK's `misc.PassThroughGenericEndpoint`
51
+ is the same shape, generated from the same LiteLLM 1.100.0 OpenAPI document — see
52
+ `codegen/manifest.json`'s `litellm-openapi` entry, left in place and unconsumed rather
53
+ than deleted, the same pattern the two UniFi entries already establish). Not published
54
+ upstream yet, so aliased onto `@homeflare/distilled-litellm@0.2.0` as a plain
55
+ `dependencies` entry, not a peer — [distilled-interim.md](./distilled-interim.md). State
56
+ did not move: props/attributes stay byte-identical, proven by the family's unchanged
57
+ existing tests (now against a fake LiteLLM exercising the real distilled protocol
58
+ through `FetchHttpClient.Fetch`, not a loopback `Bun.serve`) plus the engine-level
59
+ replace test through `fake-stack.ts`. No stack in this estate currently imports
60
+ `litellmProviders`/`LiteLLM.PassThroughEndpoint` (measured 2026-09-24 across
61
+ homeflare-landscape's own repositories, including this one's `stacks/`), so there is no
62
+ live plan to re-run.
63
+ - ⛔ **Still diverges on credentials (S24):** `LITELLM_PROXY_URL` / `LITELLM_PROXY_API_KEY`
64
+ are read at call time, now through the SDK's `CredentialsFromEnv` rather than a
65
+ hand-rolled `resolveCreds` — same divergence, unchanged by the transport swap.
66
+ - **Credentials resolution, decision 49 (2026-09-24):** the temporary typed-`ConfigError`
67
+ change was reverted in [PR #261](https://github.com/taslabs-net/homeflare-kit/pull/261).
68
+ `CredentialsFromEnv` retains distilled's own `Effect.orDie` convention. S20 forbids it
69
+ in lifecycle operations; it is not a separate credentials-layer prohibition. The same
70
+ shape in NetBox is therefore not an S20 follow-up. See [LiteLLM's Q6 resolution](./litellm.md#credentials).
71
+ 11. **Every family repeats `list: () => Effect.succeed([])`**, 30 times, and only
72
+ `R2BucketLock` and `MeshNode` declare `nuke`. The constructor already defaults `list`
73
+ (S12). **Fix:** write the reason where it differs, and declare `nuke: { skip: true }`
74
+ where nuke must never reach the object.
75
+ 12. **Resource JSDoc is not in upstream's generator format.** Zero files use `@resource`,
76
+ `**Example:**` or `### Section` (S31, H10). **Decision:** maintainer, because it moves
77
+ the house's glyph rationale into `//` comments.
78
+ 13. **Tests never use `alchemy/Test/Bun`.** Every lifecycle is proven against loopback
79
+ fakes (S28, H12). Live suites need a place to run, and that is a maintainer decision.
80
+ 14. **`discord/*` is new (2026-09-24, task-authorized, kit PR TBD) and built directly on
81
+ `@distilled.cloud/discord` — no hand-rolled `client.ts` ever existed to retire.**
82
+ `Discord.ApplicationCommand` and `Discord.GuildApplicationCommand` call the SDK's
83
+ typed operations, `catchTag`'d through the shared `DiscordOpError` union (S21), with no
84
+ status sniffing anywhere in `resource.ts`. It follows upstream's `Snippet.ts` reference
85
+ exactly for a marker-less API (S7, S8): a cold `read` returns `Unowned(attrs)`, and both
86
+ convenience constructors pipe `adopt(true)` (H5) — a stricter posture than `netbox/*`'s
87
+ documented H1 gap, not a repeat of it. Rate-limit handling is the SDK's own default
88
+ `Retry` policy (bounded: `Schedule.recurs(8)`, S26) — this family adds nothing on top.
89
+ Full detail, the live Halibut census and its ownership-handover sequence, and every SDK
90
+ gap: [`discord.md`](./discord.md). ⛔ **Diverges on S24/S25 the same way every distilled
91
+ family in this ledger does:** credentials are read at call time through the SDK's own
92
+ `CredentialsFromEnv`, not a house `alchemy/Auth` provider — unchanged from `netbox/*`
93
+ and `litellm/*`'s entries above. **Gap, not yet fixed:** no vendor constraint table
94
+ (unlike NetBox/Paperless); `options` passed through opaquely rather than modeled from
95
+ the schema (`docs/discord.md#sdk-gaps`).
@@ -0,0 +1,106 @@
1
+ # Upstream conformance: Grafana
2
+
3
+ Status: extracted from the [conformance ledger](./upstream-conformance.md); its dated
4
+ measurements and open findings are preserved below.
5
+
6
+ 15. **`grafana/*` (added 2026-09-24) ships only `Datasource` — a real SDK gap, not scope-trimming.**
7
+ Measured against the published `@distilled.cloud/grafana@1.0.0-rc.12` tarball's
8
+ `lib/services/grafana.d.ts` (4,938 lines): the package has no create/read/update/delete
9
+ operations for folders (only `updateFolderPermissions` exists), plain dashboards (only
10
+ snapshot/public-dashboard routes exist — no `POST /dashboards/db`, no
11
+ `GET/DELETE /dashboards/uid/{uid}`), alert rules or contact points (both have only a
12
+ `routeGet*Export` read-only route under `/v1/provisioning/`, no create/update/delete). S1
13
+ found no upstream `Alchemy` family for any of these either. Full detail:
14
+ [grafana.md](./grafana.md#the-sdk-gap--why-only-datasource-ships).
15
+
16
+ ✅ **S22 fixed 2026-09-24** (branch `claude2/distilled-grafana`): root cause measured to be
17
+ `skipDeprecated: true` dropping 64 operations Grafana's own spec marks `deprecated: true` in
18
+ favor of a separate, undocumented-here Kubernetes-apiserver route — not a spec omission. RFC-6902
19
+ patches un-deprecate exactly the 32 operations this finding named (folders, dashboards,
20
+ alerting-provisioning writes); the other 32 stay excluded. Shipped as
21
+ `@homeflare/distilled-grafana` (0.1.0 pre-release, 0.2.0 once this PR's `minor` changeset
22
+ releases it), aliased onto `@distilled.cloud/grafana` the same S22 route
23
+ `@homeflare/distilled-netbox` established. `Grafana.Datasource`'s props/attributes/generated code
24
+ are unchanged (diffed operation-by-operation against the prior commit).
25
+ - ⚠️ **Knowing departure from upstream (Q8, decision 49 "upstream wins", 2026-09-24, WI-13):**
26
+ upstream's own rule for a deprecated vendor API is to document it _out of scope_, not
27
+ implement it — "Deprecated APIs (superseded by Rulesets etc.) … are documented as out of
28
+ scope in `INDEX.md` rather than implemented" (A `AGENTS.md:155`). The 32 un-deprecated
29
+ operations above do the opposite: they are un-deprecated and implemented. The reason is that
30
+ there is no in-scope replacement to point at instead — Grafana's spec marks these
31
+ `deprecated: true` in favor of a Kubernetes-style aggregated apiserver under
32
+ `/apis/{folder,dashboard,...}.grafana.app/v1…`, and that route family is **absent from the
33
+ pinned spec entirely**: MEASURED 2026-09-24 in the grafana worktree
34
+ (`packages/grafana/specs/spec-mirror-grafana/specs/openapi3.json`), 0 of 207 paths start
35
+ with `/apis` — the un-deprecation patches' own descriptions already say the same thing
36
+ (`packages/grafana/patches/001-undeprecate-folders.patch.json`: "That newer route … [is] not
37
+ present in this spec at all"). Following upstream's rule literally here would mean shipping
38
+ no `Grafana.Folder`/`Dashboard`/alerting-provisioning family at all, on an API the kit
39
+ already needs. The departure is scoped to exactly these 32 operations; the other 32 stay
40
+ excluded, deprecated, out of scope, matching upstream on that half.
41
+
42
+ ✅ **`Grafana.Folder`/`Grafana.Dashboard` shipped** (PR 245, on top of the same
43
+ `@distilled.cloud/grafana` operations S22 unblocked) — the uid-required doctrine, the
44
+ provisioned-object refusal, the parentUid-is-create-only refusal and the volatile-field
45
+ normalization that makes an unchanged dashboard a true noop are all in
46
+ [grafana-folder-dashboard.md](./grafana-folder-dashboard.md), not repeated here.
47
+
48
+ ✅ **`Grafana.ContactPoint`/`Grafana.MuteTiming`/`Grafana.MessageTemplate` shipped** (kit PR
49
+ 250, first of three stacked PRs — `Grafana.AlertRuleGroup` then `Grafana.NotificationPolicy`
50
+ follow):
51
+ the foreign-provenance refusal (`alerting-provenance.ts`, an allowlist of Grafana's own
52
+ `ProvenanceNone`/`ProvenanceAPI` values, measured against `pkg/services/ngalert/models` and
53
+ Grafana's v13.1 docs) and the secret-settings-ref seam (`secret-refs.ts`, shared with
54
+ `Grafana.Datasource`) are both new shared building blocks — full detail:
55
+ [grafana-alerting.md](./grafana-alerting.md).
56
+ - 🔴 **New SDK gap found, not fixed here:** `MuteTimeInterval`'s generated type has no
57
+ `provenance`/`version` fields, and the SDK's own `TimeInterval` type is missing the real
58
+ Alertmanager time-interval fields (`weekdays`/`times`/`months`/etc.) entirely — Grafana's real
59
+ API returns and accepts all of them. NOT a data-loss gap: `@distilled.cloud/core`'s response
60
+ decoding never runs a strict schema decode (`JSON.parse` + a key-rename pass that leaves any
61
+ unmodeled key verbatim — `protocol-http.ts`'s `mapKeys`, confirmed by driving the real
62
+ operation through this family's fake-Grafana harness), so `mute-timing.ts` reads/writes both
63
+ through a locally-widened type instead. Same S22 fix route as the other named gaps
64
+ (regenerate the distilled clone's spec, copy forward) — not attempted here, since
65
+ `packages/distilled-grafana/src/` is vendored and never hand-edited.
66
+ - Measured, read-only, same census as PR 245's: `teslamate-grafana` has no contact points, mute
67
+ timings or templates configured either (consistent with the alerting-provisioning gap having
68
+ been real, not merely unexploited).
69
+
70
+ ✅ **`Grafana.AlertRuleGroup` shipped** (kit PR 254, second of three, branched fresh off `main`
71
+ after PR 250 merged): group, not per-rule, is the unit — `PUT .../rule-groups/{Group}` owns
72
+ evaluation `interval` and the full ordered `rules[]` together, which per-rule operations
73
+ cannot express (no `interval` field, no reordering). Rule order is significant, proven by a
74
+ dedicated test. The per-rule provenance refusal (each `ProvisionedAlertRule.provenance`, since
75
+ the group type itself carries none) reuses `alerting-provenance.ts` unchanged, including the
76
+ missing-means-foreign fix from PR 250's review. A declared group whose folder does not exist
77
+ refuses as a typed `BadRequest` (`RoutePutAlertRuleGroupError` has no `NotFound` case) rather
78
+ than being auto-created — this resource never calls `Grafana.Folder`'s create path. A whole-group
79
+ write silently dropping a live rule not in the declaration (found by that PR's own adversarial
80
+ review) is now warned about loudly rather than silent, never a refusal. Full detail:
81
+ [grafana-alerting-rules.md](./grafana-alerting-rules.md).
82
+
83
+ ✅ **`Grafana.NotificationPolicy` shipped** (this PR, third and last of the stacked PRs,
84
+ branched fresh off `main` after PR 254 merged): a SINGLETON — `fetchLive` never returns
85
+ `undefined` (`RouteGetPolicyTreeError` has no `NotFound` case), so this resource has no create
86
+ or delete in the sense every sibling resource does, only adopt-and-update. `destroy` never
87
+ calls `routeResetPolicyTree` without an explicit `allowReset: true` prop, a gate independent of
88
+ (and in addition to) `defaultRemovalPolicy: 'retain'` — two gates, since resetting wipes the
89
+ WHOLE instance's routing back to Grafana's bare default, not one object. The tree is opaque
90
+ `Record<string, unknown>` (same reasoning as `mute-timing.ts`'s `timeIntervals` — Grafana's
91
+ matcher-expression types are complex unions this family does not hand-model), compared via
92
+ `subset-match.ts` for Grafana-injected-default tolerance, with route order significant. The
93
+ same dropped-item warning kit PR 254 introduced for rule groups is mirrored here for dropped
94
+ routes. Full detail: [grafana-notification-policy.md](./grafana-notification-policy.md).
95
+
96
+ This completes the alerting-provisioning family this project set out to cover.
97
+ - ⛔ **Same open gaps as `forgejo/*` and `netbox/*` above:** `read` never answers `Unowned`
98
+ (H1), and credentials come from an explicit env var NAME at call time rather than an
99
+ `alchemy/Auth` provider (S24) — here the house's own `grafanaCredentials(target)`, not the
100
+ SDK's `CredentialsFromEnv`, because more than one Grafana instance exists on this estate
101
+ (grafana.md's own Credentials section).
102
+ - Measured, read-only, on `teslamate-grafana.service` (CT100, `teslamate/grafana:4.2.0`,
103
+ Grafana 13.1.3): one datasource (file-provisioned, not API-managed — a live example this
104
+ family COULD adopt), dashboards baked into the image (not API-managed either way), and no
105
+ folders, alert rules or contact points configured — consistent with the gap being real
106
+ rather than merely unexploited. No stack yet imports `@homeflare/alchemy/grafana`.